Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions src/Host/Configuration/EndpointConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,19 @@ public static WebApplication MapAppEndpoints(
app.MapControllers();
PluginApplicationConfiguration.MapEndpoints(app, plugins);

app.MapGet("/health", async (HttpContext context, IJwtKeyStore keyStore, IReadOnlyList<LoadedPlugin> plugins) =>
app.MapGet("/health", async (
HttpContext context,
IJwtKeyStore keyStore,
IReadOnlyList<LoadedPlugin> plugins,
PluginHealthExecutor healthExecutor) =>
{
var keyStoreHealthy = keyStore.GetPublicJwks().Any();

var pluginResults = new Dictionary<string, IReadOnlyList<PluginHealthResult>>();
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)
Expand Down
32 changes: 32 additions & 0 deletions src/Host/Plugins/CachedPluginHealthExecution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using AuthKit.Plugins.Abstractions.Models;

namespace Host.Plugins;

/// <summary>
/// Stores a completed plugin health execution until its cache expiry time.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The cache key is the stable plugin identifier, so results cannot be reused
/// across different plugins.
/// </para>
/// </remarks>
/// <param name="Results">The structured results returned by the plugin.</param>
/// <param name="Duration">The duration measured during the completed execution.</param>
/// <param name="ExpiresAt">The UTC time after which this entry is invalid.</param>
internal sealed record CachedPluginHealthExecution(
IReadOnlyCollection<PluginHealthResult> Results,
TimeSpan Duration,
DateTimeOffset ExpiresAt)
{
/// <summary>
/// Converts the cached entry to the public host execution result.
/// </summary>
public PluginHealthExecutionResult ToExecutionResult() =>
new(Results, Duration);
}
26 changes: 26 additions & 0 deletions src/Host/Plugins/PluginHealthExecutionOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using AuthKit.Plugins.Abstractions.Models;

namespace Host.Plugins;

/// <summary>
/// Configures host side plugin health execution and result caching.
/// </summary>
/// <remarks>
/// <para>
/// The cache stores only completed executions. A zero <see cref="CacheTtl"/>
/// disables caching, while positive value controls how long completed
/// result may be reused.
/// </para>
/// <para>
/// This option controls host observed execution behavior and does not alter the
/// plugin-owned diagnostic data in <see cref="PluginHealthResult"/>.
/// </para>
/// </remarks>
public sealed class PluginHealthExecutionOptions
{
/// <summary>
/// Gets or sets the cache lifetime for completed plugin health executions.
/// A zero value disables caching. The default is 30 seconds.
/// </summary>
public TimeSpan CacheTtl { get; set; } = TimeSpan.FromSeconds(30);
}
23 changes: 23 additions & 0 deletions src/Host/Plugins/PluginHealthExecutionResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using AuthKit.Plugins.Abstractions.Models;

namespace Host.Plugins;

/// <summary>
/// Represents plugin health execution together with host observed duration.
/// </summary>
/// <remarks>
/// <para>
/// The plugin results remain unchanged and preserve their status, reason, tags,
/// and diagnostic data. <see cref="Duration"/> is measured by the host and is
/// intentionally kept outside <see cref="PluginHealthResult.Data"/>.
/// </para>
/// <para>
/// Instances returned from the cache represent the duration of the original
/// completed execution, not the time spent serving the cached response.
/// </para>
/// </remarks>
/// <param name="Results">The structured results returned by the plugin.</param>
/// <param name="Duration">The monotonic host measured execution duration.</param>
public sealed record PluginHealthExecutionResult(
IReadOnlyCollection<PluginHealthResult> Results,
TimeSpan Duration);
107 changes: 107 additions & 0 deletions src/Host/Plugins/PluginHealthExecutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using AuthKit.Plugins.Abstractions.Models;
using Microsoft.Extensions.Options;

namespace Host.Plugins;

/// <summary>
/// Executes plugin health checks in disposable scopes and caches completed results.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Refreshes for the same plugin are serialized so concurrent requests share one
/// completed cache entry. Different plugins may refresh concurrently.
/// </para>
/// </remarks>
/// <param name="serviceProvider">The root provider used to create health check scopes.</param>
/// <param name="options">The host health execution and cache configuration.</param>
public sealed class PluginHealthExecutor(
IServiceProvider serviceProvider,
IOptions<PluginHealthExecutionOptions> options)
{
private readonly ConcurrentDictionary<string, CachedPluginHealthExecution> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _refreshGates = new(StringComparer.Ordinal);
private readonly TimeSpan _cacheTtl = ValidateTtl(options.Value.CacheTtl);

/// <summary>
/// Executes or retrieves the cached health result for plugin.
/// </summary>
/// <param name="plugin">The plugin whose health is being checked.</param>
/// <param name="cancellationToken">A token that cancels waiting or execution.</param>
/// <returns>The structured health result and host-observed duration.</returns>
/// <exception cref="OperationCanceledException">
/// Thrown when the wait or plugin health check is cancelled.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when a plugin returns no health results.
/// </exception>
public async Task<PluginHealthExecutionResult> 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<PluginHealthResult> 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;
}
3 changes: 3 additions & 0 deletions src/Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@

// === Core Config ===
builder.Services.AddSingleton(plugins);
builder.Services.Configure<PluginHealthExecutionOptions>(
builder.Configuration.GetSection("Health"));
builder.Services.AddSingleton<PluginHealthExecutor>();
builder.Services.AddAuthKitCore();

builder.Services.ConfigureApp(builder.Configuration, plugins)
Expand Down
23 changes: 19 additions & 4 deletions src/Plugins/Abstractions/Models/PluginHealthResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ namespace AuthKit.Plugins.Abstractions.Models;
/// <remarks>
/// <para>
/// <see cref="Status"/> provides the strongly typed health classification. The
/// optional <see cref="Reason"/> and <see cref="Data"/> members add context but
/// must not redefine or override that classification.
/// optional <see cref="Reason"/>, <see cref="Tags"/>, and <see cref="Data"/>
/// members add context but must not redefine or override that classification.
/// </para>
/// <para>
/// <see cref="Data"/> 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. <see cref="Tags"/> are stable classification values intended for
/// host-side filtering. The host may serialize both without assigning them
/// health semantics.
/// </para>
/// </remarks>
public sealed record PluginHealthResult
Expand All @@ -23,14 +25,17 @@ public sealed record PluginHealthResult
/// <param name="status">The strongly typed operational health state.</param>
/// <param name="reason">An optional human-readable explanation of the health state.</param>
/// <param name="data">Optional plugin-owned diagnostic data.</param>
/// <param name="tags">Optional classification values for filtering and grouping.</param>
public PluginHealthResult(
PluginHealthStatus status,
string? reason = null,
IReadOnlyDictionary<string, object>? data = null)
IReadOnlyDictionary<string, object>? data = null,
IReadOnlyCollection<string>? tags = null)
{
Status = status;
Reason = reason;
Data = data;
Tags = tags;
}

/// <summary>
Expand All @@ -47,6 +52,16 @@ public PluginHealthResult(
/// </remarks>
public string? Reason { get; init; }

/// <summary>
/// Gets optional stable classification values for filtering and grouping.
/// </summary>
/// <remarks>
/// Tags are independent from <see cref="Status"/>, <see cref="Reason"/>,
/// and <see cref="Data"/>. A tag must not be interpreted as a replacement
/// for the strongly typed health status.
/// </remarks>
public IReadOnlyCollection<string>? Tags { get; init; }

/// <summary>
/// Gets optional plugin-owned diagnostic data.
/// </summary>
Expand Down
39 changes: 36 additions & 3 deletions src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,22 +82,55 @@ public async Task<IReadOnlyList<PluginHealthResult>> CheckHealthAsync(
cancellationToken.ThrowIfCancellationRequested();
var store = services.GetService<IDocumentStore>();
if (store is null)
return [new(PluginHealthStatus.Unhealthy, "Document store is unavailable.")];
return
[
new(
PluginHealthStatus.Unhealthy,
"Document store is unavailable.",
new Dictionary<string, object>
{
["dependency"] = "document_store",
["available"] = false
},
["database", "dependency", "critical"])
];

try
{
await using var session = store.LightweightSession();
await session.Query<DeveloperToken>().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<string, object>
{
["dependency"] = "document_store",
["available"] = true
},
["database", "dependency", "readiness"])
];
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return [new(PluginHealthStatus.Unhealthy, "Developer token store is unavailable.")];
return
[
new(
PluginHealthStatus.Unhealthy,
"Developer token store is unavailable.",
new Dictionary<string, object>
{
["dependency"] = "document_store",
["available"] = false
},
["database", "dependency", "critical"])
];
}
}

Expand Down
Loading
Loading