diff --git a/Dockerfile b/Dockerfile index be17094..b623cd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,8 +36,9 @@ FROM build AS publish WORKDIR /src RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens -RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools COPY src/Plugins/Solutions/DevTokens/manifest.json /app/publish/plugins/DevTokens/manifest.json + +RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools COPY src/Plugins/Solutions/DevTools/manifest.json /app/publish/plugins/DevTools/manifest.json FROM base AS final diff --git a/src/Host/Configuration/EndpointConfiguration.cs b/src/Host/Configuration/EndpointConfiguration.cs index 7268893..f05e25c 100644 --- a/src/Host/Configuration/EndpointConfiguration.cs +++ b/src/Host/Configuration/EndpointConfiguration.cs @@ -56,15 +56,19 @@ public static WebApplication MapAppEndpoints( app.MapControllers(); PluginApplicationConfiguration.MapEndpoints(app, plugins); - app.MapGet("/health", async (HttpContext context, IJwtKeyStore keyStore, IReadOnlyList plugins) => + app.MapGet("/health", async ( + HttpContext context, + IJwtKeyStore keyStore, + IReadOnlyList plugins, + PluginHealthExecutor healthExecutor) => { var keyStoreHealthy = keyStore.GetPublicJwks().Any(); var pluginResults = new Dictionary>(); foreach (var lp in plugins) - pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync( - context.RequestServices, - context.RequestAborted); + pluginResults[lp.Plugin.Name] = (await healthExecutor.ExecuteAsync( + lp, + context.RequestAborted)).Results.ToArray(); var pluginStatus = pluginResults.Values .SelectMany(results => results) diff --git a/src/Host/Plugins/CachedPluginHealthExecution.cs b/src/Host/Plugins/CachedPluginHealthExecution.cs new file mode 100644 index 0000000..ce92838 --- /dev/null +++ b/src/Host/Plugins/CachedPluginHealthExecution.cs @@ -0,0 +1,32 @@ +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins; + +/// +/// Stores a completed plugin health execution until its cache expiry time. +/// +/// +/// +/// This host owned cache entry keeps execution duration separate from the +/// plugin owned diagnostic data. It is never created for an incomplete or +/// cancelled execution. +/// +/// +/// The cache key is the stable plugin identifier, so results cannot be reused +/// across different plugins. +/// +/// +/// The structured results returned by the plugin. +/// The duration measured during the completed execution. +/// The UTC time after which this entry is invalid. +internal sealed record CachedPluginHealthExecution( + IReadOnlyCollection Results, + TimeSpan Duration, + DateTimeOffset ExpiresAt) +{ + /// + /// Converts the cached entry to the public host execution result. + /// + public PluginHealthExecutionResult ToExecutionResult() => + new(Results, Duration); +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginHealthExecutionOptions.cs b/src/Host/Plugins/PluginHealthExecutionOptions.cs new file mode 100644 index 0000000..53cde39 --- /dev/null +++ b/src/Host/Plugins/PluginHealthExecutionOptions.cs @@ -0,0 +1,26 @@ +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins; + +/// +/// Configures host side plugin health execution and result caching. +/// +/// +/// +/// The cache stores only completed executions. A zero +/// disables caching, while positive value controls how long completed +/// result may be reused. +/// +/// +/// This option controls host observed execution behavior and does not alter the +/// plugin-owned diagnostic data in . +/// +/// +public sealed class PluginHealthExecutionOptions +{ + /// + /// Gets or sets the cache lifetime for completed plugin health executions. + /// A zero value disables caching. The default is 30 seconds. + /// + public TimeSpan CacheTtl { get; set; } = TimeSpan.FromSeconds(30); +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginHealthExecutionResult.cs b/src/Host/Plugins/PluginHealthExecutionResult.cs new file mode 100644 index 0000000..5aa74ab --- /dev/null +++ b/src/Host/Plugins/PluginHealthExecutionResult.cs @@ -0,0 +1,23 @@ +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins; + +/// +/// Represents plugin health execution together with host observed duration. +/// +/// +/// +/// The plugin results remain unchanged and preserve their status, reason, tags, +/// and diagnostic data. is measured by the host and is +/// intentionally kept outside . +/// +/// +/// Instances returned from the cache represent the duration of the original +/// completed execution, not the time spent serving the cached response. +/// +/// +/// The structured results returned by the plugin. +/// The monotonic host measured execution duration. +public sealed record PluginHealthExecutionResult( + IReadOnlyCollection Results, + TimeSpan Duration); \ No newline at end of file diff --git a/src/Host/Plugins/PluginHealthExecutor.cs b/src/Host/Plugins/PluginHealthExecutor.cs new file mode 100644 index 0000000..e40bf4f --- /dev/null +++ b/src/Host/Plugins/PluginHealthExecutor.cs @@ -0,0 +1,107 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using AuthKit.Plugins.Abstractions.Models; +using Microsoft.Extensions.Options; + +namespace Host.Plugins; + +/// +/// Executes plugin health checks in disposable scopes and caches completed results. +/// +/// +/// +/// Every uncached execution receives dedicated asynchronous dependency +/// injection scope. The scope remains alive until the plugin check completes and +/// is disposed even when the check fails or is cancelled. +/// +/// +/// Refreshes for the same plugin are serialized so concurrent requests share one +/// completed cache entry. Different plugins may refresh concurrently. +/// +/// +/// The root provider used to create health check scopes. +/// The host health execution and cache configuration. +public sealed class PluginHealthExecutor( + IServiceProvider serviceProvider, + IOptions options) +{ + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _refreshGates = new(StringComparer.Ordinal); + private readonly TimeSpan _cacheTtl = ValidateTtl(options.Value.CacheTtl); + + /// + /// Executes or retrieves the cached health result for plugin. + /// + /// The plugin whose health is being checked. + /// A token that cancels waiting or execution. + /// The structured health result and host-observed duration. + /// + /// Thrown when the wait or plugin health check is cancelled. + /// + /// + /// Thrown when a plugin returns no health results. + /// + public async Task ExecuteAsync( + LoadedPlugin plugin, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plugin); + + if (TryGetCached(plugin.Plugin.Id, out var cached)) + return cached.ToExecutionResult(); + + var gate = _refreshGates.GetOrAdd(plugin.Plugin.Id, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + if (TryGetCached(plugin.Plugin.Id, out cached)) + return cached.ToExecutionResult(); + + var stopwatch = Stopwatch.StartNew(); + IReadOnlyList results; + await using (var scope = serviceProvider.CreateAsyncScope()) + { + results = await plugin.Plugin.CheckHealthAsync( + scope.ServiceProvider, + cancellationToken); + } + + if (results is null || results.Count == 0) + throw new InvalidOperationException( + $"Plugin '{plugin.Plugin.Id}' returned no health results."); + + stopwatch.Stop(); + var execution = new PluginHealthExecutionResult(results, stopwatch.Elapsed); + if (_cacheTtl > TimeSpan.Zero) + { + _cache[plugin.Plugin.Id] = new CachedPluginHealthExecution( + execution.Results, + execution.Duration, + DateTimeOffset.UtcNow.Add(_cacheTtl)); + } + + return execution; + } + finally + { + gate.Release(); + } + } + + private bool TryGetCached(string pluginId, out CachedPluginHealthExecution cached) + { + if (_cacheTtl > TimeSpan.Zero + && _cache.TryGetValue(pluginId, out cached!) + && cached.ExpiresAt > DateTimeOffset.UtcNow) + return true; + + _cache.TryRemove(pluginId, out _); + cached = null!; + return false; + } + + private static TimeSpan ValidateTtl(TimeSpan ttl) => + ttl < TimeSpan.Zero + ? throw new ArgumentOutOfRangeException(nameof(ttl), "Health cache TTL cannot be negative.") + : ttl; +} \ No newline at end of file diff --git a/src/Host/Program.cs b/src/Host/Program.cs index bda37ef..66eef0f 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -23,6 +23,9 @@ // === Core Config === builder.Services.AddSingleton(plugins); +builder.Services.Configure( + builder.Configuration.GetSection("Health")); +builder.Services.AddSingleton(); builder.Services.AddAuthKitCore(); builder.Services.ConfigureApp(builder.Configuration, plugins) diff --git a/src/Plugins/Abstractions/Models/PluginHealthResult.cs b/src/Plugins/Abstractions/Models/PluginHealthResult.cs index 1542c1f..2f6e00d 100644 --- a/src/Plugins/Abstractions/Models/PluginHealthResult.cs +++ b/src/Plugins/Abstractions/Models/PluginHealthResult.cs @@ -6,13 +6,15 @@ namespace AuthKit.Plugins.Abstractions.Models; /// /// /// provides the strongly typed health classification. The -/// optional and members add context but -/// must not redefine or override that classification. +/// optional , , and +/// members add context but must not redefine or override that classification. /// /// /// is owned by the plugin and may contain plugin-specific /// diagnostic values such as dependency names, endpoint information, or queue -/// depth. The host may serialize this data without assigning it health semantics. +/// depth. are stable classification values intended for +/// host-side filtering. The host may serialize both without assigning them +/// health semantics. /// /// public sealed record PluginHealthResult @@ -23,14 +25,17 @@ public sealed record PluginHealthResult /// The strongly typed operational health state. /// An optional human-readable explanation of the health state. /// Optional plugin-owned diagnostic data. + /// Optional classification values for filtering and grouping. public PluginHealthResult( PluginHealthStatus status, string? reason = null, - IReadOnlyDictionary? data = null) + IReadOnlyDictionary? data = null, + IReadOnlyCollection? tags = null) { Status = status; Reason = reason; Data = data; + Tags = tags; } /// @@ -47,6 +52,16 @@ public PluginHealthResult( /// public string? Reason { get; init; } + /// + /// Gets optional stable classification values for filtering and grouping. + /// + /// + /// Tags are independent from , , + /// and . A tag must not be interpreted as a replacement + /// for the strongly typed health status. + /// + public IReadOnlyCollection? Tags { get; init; } + /// /// Gets optional plugin-owned diagnostic data. /// diff --git a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs index c2353db..d628210 100644 --- a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs +++ b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs @@ -82,14 +82,36 @@ public async Task> CheckHealthAsync( cancellationToken.ThrowIfCancellationRequested(); var store = services.GetService(); if (store is null) - return [new(PluginHealthStatus.Unhealthy, "Document store is unavailable.")]; + return + [ + new( + PluginHealthStatus.Unhealthy, + "Document store is unavailable.", + new Dictionary + { + ["dependency"] = "document_store", + ["available"] = false + }, + ["database", "dependency", "critical"]) + ]; try { await using var session = store.LightweightSession(); await session.Query().Take(1).ToListAsync(token: cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - return [new(PluginHealthStatus.Healthy, "Developer token store is available.")]; + return + [ + new( + PluginHealthStatus.Healthy, + "Developer token store is available.", + new Dictionary + { + ["dependency"] = "document_store", + ["available"] = true + }, + ["database", "dependency", "readiness"]) + ]; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -97,7 +119,18 @@ public async Task> CheckHealthAsync( } catch { - return [new(PluginHealthStatus.Unhealthy, "Developer token store is unavailable.")]; + return + [ + new( + PluginHealthStatus.Unhealthy, + "Developer token store is unavailable.", + new Dictionary + { + ["dependency"] = "document_store", + ["available"] = false + }, + ["database", "dependency", "critical"]) + ]; } } diff --git a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs index cb07bee..341f1ee 100644 --- a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs +++ b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs @@ -81,18 +81,48 @@ public Task> CheckHealthAsync( var catalog = services.GetService(); if (catalog is null) return Task.FromResult>( - [new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]); + [ + new( + PluginHealthStatus.Unhealthy, + "gRPC service catalog is unavailable.", + new Dictionary + { + ["dependency"] = "grpc_service_catalog", + ["available"] = false + }, + ["grpc", "dependency", "critical"]) + ]); try { _ = catalog.GetServices(); return Task.FromResult>( - [new(PluginHealthStatus.Healthy, "gRPC service catalog is available.")]); + [ + new( + PluginHealthStatus.Healthy, + "gRPC service catalog is available.", + new Dictionary + { + ["dependency"] = "grpc_service_catalog", + ["available"] = true + }, + ["grpc", "dependency", "readiness"]) + ]); } catch { return Task.FromResult>( - [new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]); + [ + new( + PluginHealthStatus.Unhealthy, + "gRPC service catalog is unavailable.", + new Dictionary + { + ["dependency"] = "grpc_service_catalog", + ["available"] = false + }, + ["grpc", "dependency", "critical"]) + ]); } } } \ No newline at end of file diff --git a/tests/Host/PluginHealthEndpointTests.cs b/tests/Host/PluginHealthEndpointTests.cs index 0204592..e9ce3f1 100644 --- a/tests/Host/PluginHealthEndpointTests.cs +++ b/tests/Host/PluginHealthEndpointTests.cs @@ -126,6 +126,8 @@ private static async Task BuildHostAsync( builder.Services.AddControllers(); builder.Services.AddSingleton(new StubKeyStore(keyStoreHealthy)); builder.Services.AddSingleton(plugins); + builder.Services.Configure(_ => { }); + builder.Services.AddSingleton(); var app = builder.Build(); app.MapAppEndpoints(plugins); diff --git a/tests/Host/PluginHealthExecutorTests.cs b/tests/Host/PluginHealthExecutorTests.cs new file mode 100644 index 0000000..f764508 --- /dev/null +++ b/tests/Host/PluginHealthExecutorTests.cs @@ -0,0 +1,345 @@ +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies scoped plugin health execution, host timing, and cache behavior. +/// +public sealed class PluginHealthExecutorTests +{ + [Fact] + public async Task ExecuteAsync_UsesAndDisposesDedicatedScope() + { + var probeInstances = new List(); + var services = CreateServices(TimeSpan.Zero, probeInstances); + await using var provider = services.BuildServiceProvider(); + var plugin = new TrackingPlugin(); + var executor = provider.GetRequiredService(); + + var execution = await executor.ExecuteAsync(Load(plugin)); + + Assert.Single(probeInstances); + Assert.True(probeInstances[0].Disposed); + Assert.Equal(1, plugin.Calls); + Assert.Equal(PluginHealthStatus.Healthy, execution.Results.Single().Status); + Assert.True(execution.Duration >= TimeSpan.Zero); + } + + [Fact] + public async Task ExecuteAsync_CachesCompletedExecutionWithinTtl() + { + var services = CreateServices(TimeSpan.FromMinutes(1), []); + await using var provider = services.BuildServiceProvider(); + var plugin = new TrackingPlugin(); + var executor = provider.GetRequiredService(); + + var first = await executor.ExecuteAsync(Load(plugin)); + var second = await executor.ExecuteAsync(Load(plugin)); + + Assert.Equal(1, plugin.Calls); + Assert.Equal(first.Duration, second.Duration); + Assert.Equal(first.Results, second.Results); + } + + [Fact] + public async Task ExecuteAsync_WithDisabledCache_ExecutesEveryRequest() + { + var services = CreateServices(TimeSpan.Zero, []); + await using var provider = services.BuildServiceProvider(); + var plugin = new TrackingPlugin(); + var executor = provider.GetRequiredService(); + + await executor.ExecuteAsync(Load(plugin)); + await executor.ExecuteAsync(Load(plugin)); + + Assert.Equal(2, plugin.Calls); + } + + [Fact] + public async Task ExecuteAsync_RefreshesAfterCacheTtlExpires() + { + var services = CreateServices(TimeSpan.FromMilliseconds(1), []); + await using var provider = services.BuildServiceProvider(); + var plugin = new TrackingPlugin(); + var executor = provider.GetRequiredService(); + + await executor.ExecuteAsync(Load(plugin)); + await Task.Delay(25); + await executor.ExecuteAsync(Load(plugin)); + + Assert.Equal(2, plugin.Calls); + } + + [Fact] + public async Task ExecuteAsync_DeduplicatesConcurrentRefreshes() + { + var services = CreateServices(TimeSpan.FromMinutes(1), []); + await using var provider = services.BuildServiceProvider(); + var plugin = new DelayedPlugin(); + var executor = provider.GetRequiredService(); + + await Task.WhenAll( + executor.ExecuteAsync(Load(plugin)), + executor.ExecuteAsync(Load(plugin))); + + Assert.Equal(1, plugin.Calls); + } + + [Fact] + public async Task ExecuteAsync_PropagatesFailureAndDoesNotCacheIt() + { + var services = CreateServices(TimeSpan.FromMinutes(1), []); + await using var provider = services.BuildServiceProvider(); + var plugin = new FailingOncePlugin(); + var executor = provider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => + executor.ExecuteAsync(Load(plugin))); + + Assert.Equal("health failure", exception.Message); + + var execution = await executor.ExecuteAsync(Load(plugin)); + + Assert.Equal(2, plugin.Calls); + Assert.Equal(PluginHealthStatus.Healthy, execution.Results.Single().Status); + } + + [Fact] + public async Task ExecuteAsync_IsolatesCacheEntriesBetweenPlugins() + { + var services = CreateServices(TimeSpan.FromMinutes(1), []); + await using var provider = services.BuildServiceProvider(); + var first = new IsolatedPlugin("plugin.first"); + var second = new IsolatedPlugin("plugin.second"); + var executor = provider.GetRequiredService(); + + var executions = await Task.WhenAll( + executor.ExecuteAsync(Load(first)), + executor.ExecuteAsync(Load(second))); + + await executor.ExecuteAsync(Load(first)); + await executor.ExecuteAsync(Load(second)); + + Assert.Equal(1, first.Calls); + Assert.Equal(1, second.Calls); + Assert.Equal("shared", executions[0].Results.Single().Reason); + Assert.Equal("shared", executions[1].Results.Single().Reason); + } + + [Fact] + public async Task ExecuteAsync_ReportsPositiveHostMeasuredDuration() + { + var services = CreateServices(TimeSpan.Zero, []); + await using var provider = services.BuildServiceProvider(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var plugin = new TimedPlugin(started, release); + var executor = provider.GetRequiredService(); + + var executionTask = executor.ExecuteAsync(Load(plugin)); + await started.Task; + await Task.Delay(20); + release.SetResult(); + + var execution = await executionTask; + + Assert.True(execution.Duration >= TimeSpan.FromMilliseconds(15)); + Assert.Null(execution.Results.Single().Data?["latency_ms"]); + } + + [Fact] + public async Task ExecuteAsync_CancellationDoesNotPopulateCache() + { + var services = CreateServices(TimeSpan.FromMinutes(1), []); + await using var provider = services.BuildServiceProvider(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var plugin = new CancellingPlugin(started); + var executor = provider.GetRequiredService(); + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + var cancelledExecution = executor.ExecuteAsync(Load(plugin), cancellation.Token); + await started.Task; + await Assert.ThrowsAnyAsync(() => cancelledExecution); + + await executor.ExecuteAsync(Load(plugin)); + + Assert.Equal(2, plugin.Calls); + } + + [Fact] + public async Task ExecuteAsync_DifferentPluginsExecuteConcurrently() + { + var services = CreateServices(TimeSpan.Zero, []); + await using var provider = services.BuildServiceProvider(); + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var alpha = new AlphaBarrierPlugin(barrier); + var beta = new BetaBarrierPlugin(barrier); + var executor = provider.GetRequiredService(); + + var alphaExecution = executor.ExecuteAsync(Load(alpha)); + var betaExecution = executor.ExecuteAsync(Load(beta)); + + await Task.Delay(100); + Assert.Equal(2, alpha.Calls + beta.Calls); + + barrier.SetResult(); + await Task.WhenAll(alphaExecution, betaExecution); + } + + private static ServiceCollection CreateServices( + TimeSpan cacheTtl, + List probeInstances) + { + var services = new ServiceCollection(); + services.AddScoped(_ => + { + var probe = new ScopedProbe(); + probeInstances.Add(probe); + return probe; + }); + services.Configure(options => options.CacheTtl = cacheTtl); + services.AddSingleton(); + return services; + } + + private static LoadedPlugin Load(IAuthKitPlugin plugin) => + new(plugin, plugin.GetType().Assembly, "test"); + + [PluginMetadata("tracking-health", "1.0.0", [], [], [], description: "Tracking health test")] + private sealed class TrackingPlugin : IAuthKitPlugin + { + public int Calls { get; private set; } + + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + Calls++; + _ = services.GetRequiredService(); + return Task.FromResult>( + [new(PluginHealthStatus.Healthy)]); + } + } + + [PluginMetadata("cancelling-health", "1.0.0", [], [], [], description: "Cancellation health test")] + private sealed class CancellingPlugin(TaskCompletionSource started) : IAuthKitPlugin + { + public int Calls { get; private set; } + + public async Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + Calls++; + started.TrySetResult(); + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + return [new PluginHealthResult(PluginHealthStatus.Healthy)]; + } + } + + [PluginMetadata("delayed-health", "1.0.0", [], [], [], description: "Concurrent health test")] + private sealed class DelayedPlugin : IAuthKitPlugin + { + public int Calls { get; private set; } + + public async Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + Calls++; + await Task.Delay(25, cancellationToken); + return [new(PluginHealthStatus.Healthy)]; + } + } + + [PluginMetadata("failing-once-health", "1.0.0", [], [], [], description: "Failure health test")] + private sealed class FailingOncePlugin : IAuthKitPlugin + { + public int Calls { get; private set; } + + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + Calls++; + if (Calls == 1) + return Task.FromException>( + new InvalidOperationException("health failure")); + + return Task.FromResult>( + [new(PluginHealthStatus.Healthy)]); + } + } + + [PluginMetadata("plugin.first", "1.0.0", [], [], [], description: "First isolated health test")] + private sealed class IsolatedPlugin(string id) : IAuthKitPlugin + { + public int Calls { get; private set; } + public string Id { get; } = id; + + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + Calls++; + return Task.FromResult>( + [new(PluginHealthStatus.Healthy, "shared")]); + } + } + + [PluginMetadata("timed-health", "1.0.0", [], [], [], description: "Timed health test")] + private sealed class TimedPlugin( + TaskCompletionSource started, + TaskCompletionSource release) : IAuthKitPlugin + { + public async Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + started.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return [new(PluginHealthStatus.Healthy)]; + } + } + + [PluginMetadata("barrier-alpha-health", "1.0.0", [], [], [], description: "Barrier alpha health test")] + private sealed class AlphaBarrierPlugin(TaskCompletionSource barrier) : BarrierPlugin(barrier) + { + } + + [PluginMetadata("barrier-beta-health", "1.0.0", [], [], [], description: "Barrier beta health test")] + private sealed class BetaBarrierPlugin(TaskCompletionSource barrier) : BarrierPlugin(barrier) + { + } + + private abstract class BarrierPlugin(TaskCompletionSource barrier) : IAuthKitPlugin + { + public int Calls { get; private set; } + + public async Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + Calls++; + await barrier.Task.WaitAsync(cancellationToken); + return [new PluginHealthResult(PluginHealthStatus.Healthy)]; + } + } + + private sealed class ScopedProbe : IAsyncDisposable + { + public bool Disposed { get; private set; } + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } + } +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/PluginHealthResultTests.cs b/tests/Plugins/Abstractions/PluginHealthResultTests.cs index 91b7f2a..318cda8 100644 --- a/tests/Plugins/Abstractions/PluginHealthResultTests.cs +++ b/tests/Plugins/Abstractions/PluginHealthResultTests.cs @@ -24,6 +24,7 @@ public void HealthyResult_AllowsOptionalReasonAndData() Assert.Equal(PluginHealthStatus.Healthy, result.Status); Assert.Null(result.Reason); Assert.Null(result.Data); + Assert.Null(result.Tags); } [Fact] @@ -36,7 +37,8 @@ public void Result_PreservesReasonAndDiagnosticDataThroughSerialization() { ["dependency"] = "cache", ["retry_count"] = 2 - }); + }, + ["cache", "readiness"]); var json = JsonSerializer.Serialize(result); var restored = JsonSerializer.Deserialize(json); @@ -44,11 +46,26 @@ public void Result_PreservesReasonAndDiagnosticDataThroughSerialization() Assert.NotNull(restored); Assert.Equal(PluginHealthStatus.Degraded, restored.Status); Assert.Equal("Cache is unavailable", restored.Reason); + Assert.Equal(["cache", "readiness"], restored.Tags); Assert.NotNull(restored.Data); Assert.Equal("cache", restored.Data["dependency"].ToString()); Assert.Equal("2", restored.Data["retry_count"].ToString()); } + [Fact] + public void Tags_RemainDistinctFromStatusAndDiagnosticData() + { + var result = new PluginHealthResult( + PluginHealthStatus.Unhealthy, + "Database is unavailable.", + new Dictionary { ["status"] = "healthy" }, + ["database", "critical"]); + + Assert.Equal(PluginHealthStatus.Unhealthy, result.Status); + Assert.Equal(["database", "critical"], result.Tags); + Assert.Equal("healthy", result.Data!["status"]); + } + [Fact] public async Task PluginHealthContract_PreservesMultipleResultsAndCancellationToken() {