up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
- Use Mongo2Go/in-memory stores; no network.
|
||||
- Cover sealed/unsealed transitions, staleness budgets, trust-root failures, deterministic ordering.
|
||||
- API tests via WebApplicationFactory; importer tests use local fixture bundles (no downloads).
|
||||
- If Mongo2Go fails to start (OpenSSL 1.1 missing), see `tests/AirGap/README.md` for the shim note.
|
||||
|
||||
## Delivery Discipline
|
||||
- Update sprint tracker statuses (`TODO → DOING → DONE/BLOCKED`); log decisions in Execution Log and Decisions & Risks.
|
||||
|
||||
3
src/AirGap/StellaOps.AirGap.Controller/AssemblyInfo.cs
Normal file
3
src/AirGap/StellaOps.AirGap.Controller/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.AirGap.Controller.Tests")]
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.DependencyInjection;
|
||||
@@ -14,24 +16,33 @@ public static class AirGapControllerServiceCollectionExtensions
|
||||
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<AirGapControllerMongoOptions>(configuration.GetSection("AirGap:Mongo"));
|
||||
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
|
||||
|
||||
services.AddSingleton<AirGapTelemetry>();
|
||||
services.AddSingleton<StalenessCalculator>();
|
||||
services.AddSingleton<AirGapStateService>();
|
||||
services.AddSingleton<TufMetadataValidator>();
|
||||
services.AddSingleton<RootRotationPolicy>();
|
||||
|
||||
services.AddSingleton<IAirGapStateStore>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AirGapControllerMongoOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILogger<MongoAirGapStateStore>>();
|
||||
if (string.IsNullOrWhiteSpace(opts.ConnectionString))
|
||||
{
|
||||
logger.LogInformation("AirGap controller using in-memory state store (Mongo connection string not configured).");
|
||||
return new InMemoryAirGapStateStore();
|
||||
}
|
||||
|
||||
var mongoClient = new MongoClient(opts.ConnectionString);
|
||||
var database = mongoClient.GetDatabase(string.IsNullOrWhiteSpace(opts.Database) ? "stellaops_airgap" : opts.Database);
|
||||
var collection = MongoAirGapStateStore.EnsureCollection(database);
|
||||
logger.LogInformation("AirGap controller using Mongo state store (db={Database}, collection={Collection}).", opts.Database, opts.Collection);
|
||||
return new MongoAirGapStateStore(collection);
|
||||
});
|
||||
|
||||
services.AddHostedService<AirGapStartupDiagnosticsHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,13 @@ internal static class AirGapEndpoints
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
TimeProvider timeProvider,
|
||||
AirGapTelemetry telemetry,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
telemetry.RecordStatus(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ internal static class AirGapEndpoints
|
||||
AirGapStateService service,
|
||||
StalenessCalculator stalenessCalculator,
|
||||
TimeProvider timeProvider,
|
||||
AirGapTelemetry telemetry,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -65,6 +68,7 @@ internal static class AirGapEndpoints
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken);
|
||||
var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now);
|
||||
telemetry.RecordSeal(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -72,12 +76,14 @@ internal static class AirGapEndpoints
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
TimeProvider timeProvider,
|
||||
AirGapTelemetry telemetry,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow());
|
||||
telemetry.RecordUnseal(tenantId, status);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed record AirGapStatusResponse(
|
||||
string? PolicyHash,
|
||||
TimeAnchor TimeAnchor,
|
||||
StalenessEvaluation Staleness,
|
||||
long DriftSeconds,
|
||||
long SecondsRemaining,
|
||||
DateTimeOffset LastTransitionAt,
|
||||
DateTimeOffset EvaluatedAt)
|
||||
{
|
||||
@@ -20,6 +22,8 @@ public sealed record AirGapStatusResponse(
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor,
|
||||
status.Staleness,
|
||||
status.Staleness.AgeSeconds,
|
||||
status.Staleness.SecondsRemaining,
|
||||
status.State.LastTransitionAt,
|
||||
status.EvaluatedAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.AirGap.Controller.Options;
|
||||
|
||||
public sealed class AirGapStartupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant to validate at startup. Defaults to single-tenant controller deployment.
|
||||
/// </summary>
|
||||
public string TenantId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Optional egress allowlist. When null, startup diagnostics consider it missing.
|
||||
/// </summary>
|
||||
public string[]? EgressAllowlist { get; set; }
|
||||
= null;
|
||||
|
||||
/// <summary>
|
||||
/// Trust material required to prove bundles and egress policy inputs are present.
|
||||
/// </summary>
|
||||
public TrustMaterialOptions Trust { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Pending root rotation metadata; validated when pending keys exist.
|
||||
/// </summary>
|
||||
public RotationOptions Rotation { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TrustMaterialOptions
|
||||
{
|
||||
public string RootJsonPath { get; set; } = string.Empty;
|
||||
public string SnapshotJsonPath { get; set; } = string.Empty;
|
||||
public string TimestampJsonPath { get; set; } = string.Empty;
|
||||
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(RootJsonPath)
|
||||
&& !string.IsNullOrWhiteSpace(SnapshotJsonPath)
|
||||
&& !string.IsNullOrWhiteSpace(TimestampJsonPath);
|
||||
}
|
||||
|
||||
public sealed class RotationOptions
|
||||
{
|
||||
public Dictionary<string, string> ActiveKeys { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> PendingKeys { get; set; } = new(StringComparer.Ordinal);
|
||||
public List<string> ApproverIds { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Services;
|
||||
|
||||
internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
|
||||
{
|
||||
private readonly IAirGapStateStore _stateStore;
|
||||
private readonly StalenessCalculator _stalenessCalculator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly AirGapStartupOptions _options;
|
||||
private readonly ILogger<AirGapStartupDiagnosticsHostedService> _logger;
|
||||
private readonly AirGapTelemetry _telemetry;
|
||||
private readonly TufMetadataValidator _tufValidator;
|
||||
private readonly RootRotationPolicy _rotationPolicy;
|
||||
|
||||
public AirGapStartupDiagnosticsHostedService(
|
||||
IAirGapStateStore stateStore,
|
||||
StalenessCalculator stalenessCalculator,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<AirGapStartupOptions> options,
|
||||
ILogger<AirGapStartupDiagnosticsHostedService> logger,
|
||||
AirGapTelemetry telemetry,
|
||||
TufMetadataValidator tufValidator,
|
||||
RootRotationPolicy rotationPolicy)
|
||||
{
|
||||
_stateStore = stateStore;
|
||||
_stalenessCalculator = stalenessCalculator;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_telemetry = telemetry;
|
||||
_tufValidator = tufValidator;
|
||||
_rotationPolicy = rotationPolicy;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = string.IsNullOrWhiteSpace(_options.TenantId) ? "default" : _options.TenantId;
|
||||
var state = await _stateStore.GetAsync(tenantId, cancellationToken);
|
||||
|
||||
if (!state.Sealed)
|
||||
{
|
||||
_logger.LogInformation("AirGap startup diagnostics skipped: tenant {TenantId} not sealed.", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, now);
|
||||
var failures = new List<string>();
|
||||
|
||||
if (_options.EgressAllowlist is null)
|
||||
{
|
||||
failures.Add("egress-allowlist-missing");
|
||||
}
|
||||
|
||||
if (state.TimeAnchor == TimeAnchor.Unknown)
|
||||
{
|
||||
failures.Add("time-anchor-missing");
|
||||
}
|
||||
else if (staleness.IsBreach)
|
||||
{
|
||||
failures.Add("time-anchor-stale");
|
||||
}
|
||||
|
||||
var trustResult = ValidateTrustMaterials(_options.Trust);
|
||||
if (!trustResult.IsValid)
|
||||
{
|
||||
failures.Add($"trust:{trustResult.Reason}");
|
||||
}
|
||||
|
||||
var rotationResult = ValidateRotation(_options.Rotation);
|
||||
if (!rotationResult.IsValid)
|
||||
{
|
||||
failures.Add($"rotation:{rotationResult.Reason}");
|
||||
}
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var reason = string.Join(',', failures);
|
||||
_telemetry.RecordStartupBlocked(tenantId, reason, staleness);
|
||||
_logger.LogCritical(
|
||||
"AirGap sealed-startup blocked tenant={TenantId} reasons={Reasons} policy_hash={PolicyHash} anchor_digest={Anchor}",
|
||||
tenantId,
|
||||
reason,
|
||||
state.PolicyHash,
|
||||
state.TimeAnchor.TokenDigest);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{reason}");
|
||||
}
|
||||
|
||||
_telemetry.RecordStartupPassed(tenantId, staleness, _options.EgressAllowlist?.Length ?? 0);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private StartupCheckResult ValidateTrustMaterials(TrustMaterialOptions trust)
|
||||
{
|
||||
if (!trust.IsConfigured)
|
||||
{
|
||||
return StartupCheckResult.Failure("trust-roots-missing");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rootJson = File.ReadAllText(trust.RootJsonPath);
|
||||
var snapshotJson = File.ReadAllText(trust.SnapshotJsonPath);
|
||||
var timestampJson = File.ReadAllText(trust.TimestampJsonPath);
|
||||
var result = _tufValidator.Validate(rootJson, snapshotJson, timestampJson);
|
||||
return result.IsValid
|
||||
? StartupCheckResult.Success()
|
||||
: StartupCheckResult.Failure(result.Reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StartupCheckResult.Failure($"trust-read-failed:{ex.GetType().Name.ToLowerInvariant()}");
|
||||
}
|
||||
}
|
||||
|
||||
private StartupCheckResult ValidateRotation(RotationOptions rotation)
|
||||
{
|
||||
if (rotation.PendingKeys.Count == 0)
|
||||
{
|
||||
return StartupCheckResult.Success();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var active = DecodeKeys(rotation.ActiveKeys);
|
||||
var pending = DecodeKeys(rotation.PendingKeys);
|
||||
var result = _rotationPolicy.Validate(active, pending, rotation.ApproverIds);
|
||||
return result.IsValid
|
||||
? StartupCheckResult.Success()
|
||||
: StartupCheckResult.Failure(result.Reason);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return StartupCheckResult.Failure("rotation-key-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, byte[]> DecodeKeys(Dictionary<string, string> source)
|
||||
{
|
||||
var decoded = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
foreach (var kvp in source)
|
||||
{
|
||||
decoded[kvp.Key] = Convert.FromBase64String(kvp.Value);
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private sealed record StartupCheckResult(bool IsValid, string Reason)
|
||||
{
|
||||
public static StartupCheckResult Success() => new(true, "ok");
|
||||
public static StartupCheckResult Failure(string reason) => new(false, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised metrics + trace hooks for the AirGap controller.
|
||||
/// </summary>
|
||||
public sealed class AirGapTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Controller", "1.0.0");
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.AirGap.Controller");
|
||||
|
||||
private static readonly Counter<long> SealCounter = Meter.CreateCounter<long>("airgap_seal_total");
|
||||
private static readonly Counter<long> UnsealCounter = Meter.CreateCounter<long>("airgap_unseal_total");
|
||||
private static readonly Counter<long> StartupBlockedCounter = Meter.CreateCounter<long>("airgap_startup_blocked_total");
|
||||
|
||||
private readonly ConcurrentDictionary<string, (long Age, long Budget)> _latestByTenant = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
private readonly ObservableGauge<long> _budgetGauge;
|
||||
private readonly ILogger<AirGapTelemetry> _logger;
|
||||
|
||||
public AirGapTelemetry(ILogger<AirGapTelemetry> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
|
||||
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveAges()
|
||||
{
|
||||
foreach (var kvp in _latestByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Age, new KeyValuePair<string, object?>("tenant", kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<long>> ObserveBudgets()
|
||||
{
|
||||
foreach (var kvp in _latestByTenant)
|
||||
{
|
||||
yield return new Measurement<long>(kvp.Value.Budget, new KeyValuePair<string, object?>("tenant", kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordStatus(string tenantId, AirGapStatus status)
|
||||
{
|
||||
_latestByTenant[tenantId] = (status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
|
||||
|
||||
using var activity = ActivitySource.StartActivity("airgap.status.read");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("sealed", status.State.Sealed);
|
||||
activity?.SetTag("policy_hash", status.State.PolicyHash);
|
||||
activity?.SetTag("anchor_source", status.State.TimeAnchor.Source);
|
||||
activity?.SetTag("staleness_age_seconds", status.Staleness.AgeSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.status.read tenant={Tenant} sealed={Sealed} policy_hash={PolicyHash} anchor_source={Source} age_seconds={Age}",
|
||||
tenantId,
|
||||
status.State.Sealed,
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor.Source,
|
||||
status.Staleness.AgeSeconds);
|
||||
}
|
||||
|
||||
public void RecordSeal(string tenantId, AirGapStatus status)
|
||||
{
|
||||
SealCounter.Add(1, new TagList { { "tenant", tenantId }, { "sealed", true } });
|
||||
RecordStatus(tenantId, status);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.sealed tenant={Tenant} policy_hash={PolicyHash} anchor_source={Source} anchor_digest={Digest} age_seconds={Age}",
|
||||
tenantId,
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor.Source,
|
||||
status.State.TimeAnchor.TokenDigest,
|
||||
status.Staleness.AgeSeconds);
|
||||
}
|
||||
|
||||
public void RecordUnseal(string tenantId, AirGapStatus status)
|
||||
{
|
||||
UnsealCounter.Add(1, new TagList { { "tenant", tenantId }, { "sealed", false } });
|
||||
RecordStatus(tenantId, status);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.unsealed tenant={Tenant} last_transition_at={TransitionAt}",
|
||||
tenantId,
|
||||
status.State.LastTransitionAt);
|
||||
}
|
||||
|
||||
public void RecordStartupBlocked(string tenantId, string reason, StalenessEvaluation staleness)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
StartupBlockedCounter.Add(1, new TagList { { "tenant", tenantId }, { "reason", reason } });
|
||||
_logger.LogCritical("airgap.startup.validation failed tenant={Tenant} reason={Reason}", tenantId, reason);
|
||||
}
|
||||
|
||||
public void RecordStartupPassed(string tenantId, StalenessEvaluation staleness, int allowlistCount)
|
||||
{
|
||||
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
|
||||
using var activity = ActivitySource.StartActivity("airgap.startup.validation");
|
||||
activity?.SetTag("tenant", tenantId);
|
||||
activity?.SetTag("result", "success");
|
||||
activity?.SetTag("allowlist_count", allowlistCount);
|
||||
activity?.SetTag("staleness_age_seconds", staleness.AgeSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"airgap.startup.validation passed tenant={Tenant} allowlist_count={AllowlistCount} anchor_age_seconds={Age}",
|
||||
tenantId,
|
||||
allowlistCount,
|
||||
staleness.AgeSeconds);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
|
||||
@@ -27,6 +27,19 @@ public sealed class AirGapOptionsValidator : IValidateOptions<AirGapOptions>
|
||||
// no-op; explicitly allowed for offline testing
|
||||
}
|
||||
|
||||
foreach (var kvp in options.ContentBudgets)
|
||||
{
|
||||
if (kvp.Value.WarningSeconds < 0 || kvp.Value.BreachSeconds < 0)
|
||||
{
|
||||
return ValidateOptionsResult.Fail($"Content budget '{kvp.Key}' must be non-negative");
|
||||
}
|
||||
|
||||
if (kvp.Value.WarningSeconds > kvp.Value.BreachSeconds)
|
||||
{
|
||||
return ValidateOptionsResult.Fail($"Content budget '{kvp.Key}' warning cannot exceed breach");
|
||||
}
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@ public sealed class AirGapOptions
|
||||
|
||||
public StalenessOptions Staleness { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-content staleness budgets (advisories, vex, policy). Values fall back to global staleness when missing.
|
||||
/// </summary>
|
||||
public Dictionary<string, StalenessOptions> ContentBudgets { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "advisories", new StalenessOptions { WarningSeconds = StalenessBudget.Default.WarningSeconds, BreachSeconds = StalenessBudget.Default.BreachSeconds } },
|
||||
{ "vex", new StalenessOptions { WarningSeconds = StalenessBudget.Default.WarningSeconds, BreachSeconds = StalenessBudget.Default.BreachSeconds } },
|
||||
{ "policy", new StalenessOptions { WarningSeconds = StalenessBudget.Default.WarningSeconds, BreachSeconds = StalenessBudget.Default.BreachSeconds } }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Path to trust roots bundle (JSON). Used by AirGap Time to validate anchors when supplied.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,5 +7,6 @@ public sealed record StalenessEvaluation(
|
||||
bool IsWarning,
|
||||
bool IsBreach)
|
||||
{
|
||||
public long SecondsRemaining => Math.Max(0, BreachSeconds - AgeSeconds);
|
||||
public static StalenessEvaluation Unknown => new(0, 0, 0, false, false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ public sealed record TimeStatus(
|
||||
TimeAnchor Anchor,
|
||||
StalenessEvaluation Staleness,
|
||||
StalenessBudget Budget,
|
||||
IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
|
||||
DateTimeOffset EvaluatedAtUtc)
|
||||
{
|
||||
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
|
||||
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, new Dictionary<string, StalenessEvaluation>(), DateTimeOffset.UnixEpoch);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed record TimeStatusDto(
|
||||
[property: JsonPropertyName("breachSeconds")] long BreachSeconds,
|
||||
[property: JsonPropertyName("isWarning")] bool IsWarning,
|
||||
[property: JsonPropertyName("isBreach")] bool IsBreach,
|
||||
[property: JsonPropertyName("contentStaleness")] IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
|
||||
[property: JsonPropertyName("evaluatedAtUtc")] string EvaluatedAtUtc)
|
||||
{
|
||||
public static TimeStatusDto FromStatus(TimeStatus status)
|
||||
@@ -29,6 +30,7 @@ public sealed record TimeStatusDto(
|
||||
status.Staleness.BreachSeconds,
|
||||
status.Staleness.IsWarning,
|
||||
status.Staleness.IsBreach,
|
||||
status.ContentStaleness,
|
||||
status.EvaluatedAtUtc.ToUniversalTime().ToString("O"));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using StellaOps.AirGap.Time.Parsing;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddSingleton<StalenessCalculator>();
|
||||
builder.Services.AddSingleton<TimeTelemetry>();
|
||||
builder.Services.AddSingleton<TimeStatusService>();
|
||||
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
|
||||
builder.Services.AddSingleton<TimeVerificationService>();
|
||||
|
||||
@@ -22,4 +22,17 @@ public sealed class StalenessCalculator
|
||||
|
||||
return new StalenessEvaluation(ageSeconds, budget.WarningSeconds, budget.BreachSeconds, isWarning, isBreach);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, StalenessEvaluation> EvaluateContent(
|
||||
TimeAnchor anchor,
|
||||
IReadOnlyDictionary<string, StalenessBudget> budgets,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
var result = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in budgets)
|
||||
{
|
||||
result[kvp.Key] = Evaluate(anchor, kvp.Value, nowUtc);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
@@ -10,11 +11,15 @@ public sealed class TimeStatusService
|
||||
{
|
||||
private readonly ITimeAnchorStore _store;
|
||||
private readonly StalenessCalculator _calculator;
|
||||
private readonly TimeTelemetry _telemetry;
|
||||
private readonly IReadOnlyDictionary<string, StalenessBudget> _contentBudgets;
|
||||
|
||||
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator)
|
||||
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator, TimeTelemetry telemetry, IOptions<AirGapOptions> options)
|
||||
{
|
||||
_store = store;
|
||||
_calculator = calculator;
|
||||
_telemetry = telemetry;
|
||||
_contentBudgets = BuildContentBudgets(options.Value);
|
||||
}
|
||||
|
||||
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
|
||||
@@ -27,6 +32,29 @@ public sealed class TimeStatusService
|
||||
{
|
||||
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
|
||||
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
|
||||
return new TimeStatus(anchor, eval, budget, nowUtc);
|
||||
var content = _calculator.EvaluateContent(anchor, _contentBudgets, nowUtc);
|
||||
var status = new TimeStatus(anchor, eval, budget, content, nowUtc);
|
||||
_telemetry.Record(tenantId, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, StalenessBudget> BuildContentBudgets(AirGapOptions opts)
|
||||
{
|
||||
var dict = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in opts.ContentBudgets)
|
||||
{
|
||||
dict[kvp.Key] = new StalenessBudget(kvp.Value.WarningSeconds, kvp.Value.BreachSeconds);
|
||||
}
|
||||
|
||||
// Ensure common keys exist.
|
||||
foreach (var key in new[] { "advisories", "vex", "policy" })
|
||||
{
|
||||
if (!dict.ContainsKey(key))
|
||||
{
|
||||
dict[key] = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
52
src/AirGap/StellaOps.AirGap.Time/Services/TimeTelemetry.cs
Normal file
52
src/AirGap/StellaOps.AirGap.Time/Services/TimeTelemetry.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class TimeTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.Ordinal);
|
||||
|
||||
private static readonly ObservableGauge<long> AnchorAgeGauge = Meter.CreateObservableGauge(
|
||||
"airgap_time_anchor_age_seconds",
|
||||
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
|
||||
|
||||
private static readonly Counter<long> StatusCounter = Meter.CreateCounter<long>("airgap_time_anchor_status_total");
|
||||
private static readonly Counter<long> WarningCounter = Meter.CreateCounter<long>("airgap_time_anchor_warning_total");
|
||||
private static readonly Counter<long> BreachCounter = Meter.CreateCounter<long>("airgap_time_anchor_breach_total");
|
||||
|
||||
public void Record(string tenantId, Models.TimeStatus status)
|
||||
{
|
||||
var snapshot = new Snapshot(status.Staleness.AgeSeconds, status.Staleness.IsWarning, status.Staleness.IsBreach);
|
||||
_latest[tenantId] = snapshot;
|
||||
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", tenantId },
|
||||
{ "is_warning", status.Staleness.IsWarning },
|
||||
{ "is_breach", status.Staleness.IsBreach }
|
||||
};
|
||||
|
||||
StatusCounter.Add(1, tags);
|
||||
|
||||
if (status.Staleness.IsWarning)
|
||||
{
|
||||
WarningCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
BreachCounter.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
public Snapshot? GetLatest(string tenantId)
|
||||
{
|
||||
return _latest.TryGetValue(tenantId, out var snap) ? snap : null;
|
||||
}
|
||||
|
||||
public sealed record Snapshot(long AgeSeconds, bool IsWarning, bool IsBreach);
|
||||
}
|
||||
@@ -13,15 +13,3 @@ responses:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
HealthResponse:
|
||||
description: Health envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [status, service]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
service:
|
||||
type: string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -160,6 +160,7 @@ paths:
|
||||
- completed
|
||||
description: Optional status filter
|
||||
- $ref: ../_shared/parameters/paging.yaml#/parameters/LimitParam
|
||||
- $ref: ../_shared/parameters/paging.yaml#/parameters/CursorParam
|
||||
- $ref: ../_shared/parameters/tenant.yaml#/parameters/TenantParam
|
||||
responses:
|
||||
'200':
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,6 @@
|
||||
| OAS-61-001 | DONE | Scaffold per-service OpenAPI 3.1 files with shared components, info blocks, and initial path stubs. |
|
||||
| OAS-61-002 | DONE (2025-11-18) | Composer (`compose.mjs`) emits `stella.yaml` with namespaced paths/components; CI job validates aggregate stays up to date. |
|
||||
| OAS-62-001 | DONE (2025-11-26) | Added examples across Authority, Policy, Orchestrator, Scheduler, Export, and Graph stubs covering top flows; standard error envelopes present via shared components. |
|
||||
| OAS-62-002 | DOING | Added rules for 2xx examples and /jobs Idempotency-Key; extend to pagination/idempotency/naming coverage (current lint is warning-free). |
|
||||
| OAS-63-001 | TODO | Implement compatibility diff tooling comparing previous release specs; classify breaking vs additive changes. |
|
||||
| OAS-62-002 | DONE (2025-11-26) | Added pagination/Idempotency-Key/operationId lint rules; enforced cursor on orchestrator jobs list and kept lint clean. |
|
||||
| OAS-63-001 | DONE (2025-11-26) | Compat diff now tracks parameter adds/removals/requiredness, request bodies, and response content types with updated fixtures/tests. |
|
||||
| OAS-63-002 | DONE (2025-11-24) | Discovery endpoint metadata and schema extensions added; composed spec exports `/.well-known/openapi` entry. |
|
||||
|
||||
45
src/Bench/StellaOps.Bench/Determinism/README.md
Normal file
45
src/Bench/StellaOps.Bench/Determinism/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Determinism Benchmark Harness (BENCH-DETERMINISM-401-057)
|
||||
|
||||
Location: `src/Bench/StellaOps.Bench/Determinism`
|
||||
|
||||
## What it does
|
||||
- Runs a deterministic, offline-friendly benchmark that hashes scanner outputs for paired SBOM/VEX inputs.
|
||||
- Produces `results.csv`, `inputs.sha256`, and `summary.json` capturing determinism rate.
|
||||
- Ships with a built-in mock scanner so CI/offline runs do not need external tools.
|
||||
|
||||
## Quick start
|
||||
```sh
|
||||
cd src/Bench/StellaOps.Bench/Determinism
|
||||
python3 run_bench.py --shuffle --runs 3 --output out
|
||||
```
|
||||
|
||||
Outputs land in `out/`:
|
||||
- `results.csv` – per-run hashes (mode/run/scanner)
|
||||
- `inputs.sha256` – deterministic manifest of SBOM/VEX/config inputs
|
||||
- `summary.json` – aggregate determinism rate
|
||||
|
||||
## Inputs
|
||||
- SBOMs: `inputs/sboms/*.json` (sample SPDX provided)
|
||||
- VEX: `inputs/vex/*.json` (sample OpenVEX provided)
|
||||
- Scanner config: `configs/scanners.json` (defaults to built-in mock scanner)
|
||||
|
||||
## Adding real scanners
|
||||
1. Add an entry to `configs/scanners.json` with `kind: "command"` and a command array, e.g.:
|
||||
```json
|
||||
{
|
||||
"name": "scannerX",
|
||||
"kind": "command",
|
||||
"command": ["python", "../../scripts/scannerX_wrapper.py", "{sbom}", "{vex}"]
|
||||
}
|
||||
```
|
||||
2. Commands must write JSON with a top-level `findings` array; each finding should include `purl`, `vulnerability`, `status`, and `base_score`.
|
||||
3. Keep commands offline and deterministic; pin any feeds to local bundles before running.
|
||||
|
||||
## Determinism expectations
|
||||
- Canonical and shuffled runs should yield identical hashes per scanner/SBOM/VEX tuple.
|
||||
- CI should treat determinism_rate < 0.95 as a failure once wired into workflows.
|
||||
|
||||
## Maintenance
|
||||
- Tests live in `tests/` and cover shuffle stability + manifest generation.
|
||||
- Update `docs/benchmarks/signals/bench-determinism.md` when inputs/outputs change.
|
||||
- Mirror task status in `docs/implplan/SPRINT_0512_0001_0001_bench.md` and `src/Bench/StellaOps.Bench/TASKS.md`.
|
||||
0
src/Bench/StellaOps.Bench/Determinism/__init__.py
Normal file
0
src/Bench/StellaOps.Bench/Determinism/__init__.py
Normal file
Binary file not shown.
12
src/Bench/StellaOps.Bench/Determinism/configs/scanners.json
Normal file
12
src/Bench/StellaOps.Bench/Determinism/configs/scanners.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scanners": [
|
||||
{
|
||||
"name": "mock",
|
||||
"kind": "mock",
|
||||
"description": "Deterministic mock scanner used for CI/offline parity",
|
||||
"parameters": {
|
||||
"severity_bias": 0.25
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"spdxVersion": "SPDX-3.0",
|
||||
"documentNamespace": "https://stellaops.local/spdx/sample-spdx",
|
||||
"packages": [
|
||||
{
|
||||
"name": "demo-lib",
|
||||
"versionInfo": "1.0.0",
|
||||
"purl": "pkg:pypi/demo-lib@1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "demo-cli",
|
||||
"versionInfo": "0.4.2",
|
||||
"purl": "pkg:generic/demo-cli@0.4.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2024-0001",
|
||||
"products": ["pkg:pypi/demo-lib@1.0.0"],
|
||||
"status": "affected",
|
||||
"justification": "known_exploited",
|
||||
"timestamp": "2025-11-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"vulnerability": "CVE-2023-9999",
|
||||
"products": ["pkg:generic/demo-cli@0.4.2"],
|
||||
"status": "not_affected",
|
||||
"justification": "vulnerable_code_not_present",
|
||||
"timestamp": "2025-10-28T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
38453c9c0e0a90d22d7048d3201bf1b5665eb483e6682db1a7112f8e4f4fa1e6 configs/scanners.json
|
||||
577f932bbb00dbd596e46b96d5fbb9561506c7730c097e381a6b34de40402329 inputs/sboms/sample-spdx.json
|
||||
1b54ce4087800cfe1d5ac439c10a1f131b7476b2093b79d8cd0a29169314291f inputs/vex/sample-openvex.json
|
||||
21
src/Bench/StellaOps.Bench/Determinism/results/results.csv
Normal file
21
src/Bench/StellaOps.Bench/Determinism/results/results.csv
Normal file
@@ -0,0 +1,21 @@
|
||||
scanner,sbom,vex,mode,run,hash,finding_count
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,0,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,0,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,1,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,1,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,2,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,2,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,3,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,3,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,4,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,4,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,5,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,5,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,6,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,6,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,7,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,7,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,8,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,8,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,canonical,9,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
mock,sample-spdx.json,sample-openvex.json,shuffled,9,d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18,2
|
||||
|
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"determinism_rate": 1.0
|
||||
}
|
||||
309
src/Bench/StellaOps.Bench/Determinism/run_bench.py
Normal file
309
src/Bench/StellaOps.Bench/Determinism/run_bench.py
Normal file
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Determinism benchmark harness for BENCH-DETERMINISM-401-057.
|
||||
|
||||
- Offline by default; uses a built-in mock scanner that derives findings from
|
||||
SBOM and VEX documents without external calls.
|
||||
- Produces deterministic hashes for canonical and (optionally) shuffled inputs.
|
||||
- Writes `results.csv` and `inputs.sha256` to the chosen output directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Sequence
|
||||
import random
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scanner:
|
||||
name: str
|
||||
kind: str # "mock" or "command"
|
||||
command: Sequence[str] | None = None
|
||||
parameters: Dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ---------- utility helpers ----------
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def dump_canonical(obj: Any) -> bytes:
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def shuffle_obj(obj: Any, rng: random.Random) -> Any:
|
||||
if isinstance(obj, list):
|
||||
shuffled = [shuffle_obj(item, rng) for item in obj]
|
||||
rng.shuffle(shuffled)
|
||||
return shuffled
|
||||
if isinstance(obj, dict):
|
||||
items = list(obj.items())
|
||||
rng.shuffle(items)
|
||||
return {k: shuffle_obj(v, rng) for k, v in items}
|
||||
return obj # primitive
|
||||
|
||||
|
||||
def stable_int(value: str, modulo: int) -> int:
|
||||
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
return int(digest[:16], 16) % modulo
|
||||
|
||||
|
||||
# ---------- mock scanner ----------
|
||||
|
||||
def run_mock_scanner(sbom: Dict[str, Any], vex: Dict[str, Any], parameters: Dict[str, Any] | None) -> Dict[str, Any]:
|
||||
severity_bias = float(parameters.get("severity_bias", 0.0)) if parameters else 0.0
|
||||
packages = sbom.get("packages", [])
|
||||
statements = vex.get("statements", [])
|
||||
|
||||
findings: List[Dict[str, Any]] = []
|
||||
for stmt in statements:
|
||||
vuln = stmt.get("vulnerability")
|
||||
status = stmt.get("status", "unknown")
|
||||
for product in stmt.get("products", []):
|
||||
score_seed = stable_int(f"{product}:{vuln}", 600)
|
||||
score = (score_seed / 10.0) + severity_bias
|
||||
findings.append(
|
||||
{
|
||||
"purl": product,
|
||||
"vulnerability": vuln,
|
||||
"status": status,
|
||||
"base_score": round(score, 1),
|
||||
}
|
||||
)
|
||||
|
||||
# Add packages with no statements as informational rows
|
||||
seen_products = {f["purl"] for f in findings}
|
||||
for pkg in packages:
|
||||
purl = pkg.get("purl")
|
||||
if purl and purl not in seen_products:
|
||||
findings.append(
|
||||
{
|
||||
"purl": purl,
|
||||
"vulnerability": "NONE",
|
||||
"status": "unknown",
|
||||
"base_score": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
findings.sort(key=lambda f: (f.get("purl", ""), f.get("vulnerability", "")))
|
||||
return {"scanner": "mock", "findings": findings}
|
||||
|
||||
|
||||
# ---------- runners ----------
|
||||
|
||||
def run_scanner(scanner: Scanner, sbom_path: Path, vex_path: Path, sbom_obj: Dict[str, Any], vex_obj: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if scanner.kind == "mock":
|
||||
return run_mock_scanner(sbom_obj, vex_obj, scanner.parameters)
|
||||
|
||||
if scanner.kind == "command":
|
||||
if scanner.command is None:
|
||||
raise ValueError(f"Scanner {scanner.name} missing command")
|
||||
cmd = [part.format(sbom=sbom_path, vex=vex_path) for part in scanner.command]
|
||||
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
raise ValueError(f"Unsupported scanner kind: {scanner.kind}")
|
||||
|
||||
|
||||
def canonical_hash(scanner_name: str, sbom_path: Path, vex_path: Path, normalized_findings: List[Dict[str, Any]]) -> str:
|
||||
payload = {
|
||||
"scanner": scanner_name,
|
||||
"sbom": sbom_path.name,
|
||||
"vex": vex_path.name,
|
||||
"findings": normalized_findings,
|
||||
}
|
||||
return sha256_bytes(dump_canonical(payload))
|
||||
|
||||
|
||||
def normalize_output(raw: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
findings = raw.get("findings", [])
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for entry in findings:
|
||||
normalized.append(
|
||||
{
|
||||
"purl": entry.get("purl", ""),
|
||||
"vulnerability": entry.get("vulnerability", ""),
|
||||
"status": entry.get("status", "unknown"),
|
||||
"base_score": float(entry.get("base_score", 0.0)),
|
||||
}
|
||||
)
|
||||
normalized.sort(key=lambda f: (f["purl"], f["vulnerability"]))
|
||||
return normalized
|
||||
|
||||
|
||||
def write_results(results: List[Dict[str, Any]], output_csv: Path) -> None:
|
||||
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = ["scanner", "sbom", "vex", "mode", "run", "hash", "finding_count"]
|
||||
with output_csv.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for row in results:
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def write_inputs_manifest(inputs: List[Path], manifest_path: Path) -> None:
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines: List[str] = []
|
||||
for path in sorted(inputs, key=lambda p: str(p)):
|
||||
digest = sha256_bytes(path.read_bytes())
|
||||
try:
|
||||
rel_path = path.resolve().relative_to(Path.cwd().resolve())
|
||||
except ValueError:
|
||||
rel_path = path.resolve()
|
||||
lines.append(f"{digest} {rel_path.as_posix()}\n")
|
||||
with manifest_path.open("w", encoding="utf-8") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def load_scanners(config_path: Path) -> List[Scanner]:
|
||||
cfg = load_json(config_path)
|
||||
scanners = []
|
||||
for entry in cfg.get("scanners", []):
|
||||
scanners.append(
|
||||
Scanner(
|
||||
name=entry.get("name", "unknown"),
|
||||
kind=entry.get("kind", "mock"),
|
||||
command=entry.get("command"),
|
||||
parameters=entry.get("parameters", {}),
|
||||
)
|
||||
)
|
||||
return scanners
|
||||
|
||||
|
||||
def run_bench(
|
||||
sboms: Sequence[Path],
|
||||
vexes: Sequence[Path],
|
||||
scanners: Sequence[Scanner],
|
||||
runs: int,
|
||||
shuffle: bool,
|
||||
output_dir: Path,
|
||||
manifest_extras: Sequence[Path] | None = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
if len(sboms) != len(vexes):
|
||||
raise ValueError("SBOM/VEX counts must match for pairwise runs")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for sbom_path, vex_path in zip(sboms, vexes):
|
||||
sbom_obj = load_json(sbom_path)
|
||||
vex_obj = load_json(vex_path)
|
||||
|
||||
for scanner in scanners:
|
||||
for run in range(runs):
|
||||
for mode in ("canonical", "shuffled" if shuffle else ""):
|
||||
if not mode:
|
||||
continue
|
||||
sbom_candidate = deepcopy(sbom_obj)
|
||||
vex_candidate = deepcopy(vex_obj)
|
||||
if mode == "shuffled":
|
||||
seed = sha256_bytes(f"{sbom_path}:{vex_path}:{run}:{scanner.name}".encode("utf-8"))
|
||||
rng = random.Random(int(seed[:16], 16))
|
||||
sbom_candidate = shuffle_obj(sbom_candidate, rng)
|
||||
vex_candidate = shuffle_obj(vex_candidate, rng)
|
||||
|
||||
raw_output = run_scanner(scanner, sbom_path, vex_path, sbom_candidate, vex_candidate)
|
||||
normalized = normalize_output(raw_output)
|
||||
results.append(
|
||||
{
|
||||
"scanner": scanner.name,
|
||||
"sbom": sbom_path.name,
|
||||
"vex": vex_path.name,
|
||||
"mode": mode,
|
||||
"run": run,
|
||||
"hash": canonical_hash(scanner.name, sbom_path, vex_path, normalized),
|
||||
"finding_count": len(normalized),
|
||||
}
|
||||
)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return results
|
||||
|
||||
|
||||
def compute_determinism_rate(results: List[Dict[str, Any]]) -> float:
|
||||
by_key: Dict[tuple, List[str]] = {}
|
||||
for row in results:
|
||||
key = (row["scanner"], row["sbom"], row["vex"], row["mode"])
|
||||
by_key.setdefault(key, []).append(row["hash"])
|
||||
|
||||
stable = 0
|
||||
total = 0
|
||||
for hashes in by_key.values():
|
||||
total += len(hashes)
|
||||
if len(set(hashes)) == 1:
|
||||
stable += len(hashes)
|
||||
return stable / total if total else 0.0
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Determinism benchmark harness")
|
||||
parser.add_argument("--sboms", nargs="*", default=["inputs/sboms/*.json"], help="Glob(s) for SBOM inputs")
|
||||
parser.add_argument("--vex", nargs="*", default=["inputs/vex/*.json"], help="Glob(s) for VEX inputs")
|
||||
parser.add_argument("--config", default="configs/scanners.json", help="Scanner config JSON path")
|
||||
parser.add_argument("--runs", type=int, default=10, help="Runs per scanner/SBOM pair")
|
||||
parser.add_argument("--shuffle", action="store_true", help="Enable shuffled-order runs")
|
||||
parser.add_argument("--output", default="results", help="Output directory")
|
||||
parser.add_argument(
|
||||
"--manifest-extra",
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="Extra files (or globs) to include in inputs.sha256 (e.g., frozen feeds)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def expand_globs(patterns: Iterable[str]) -> List[Path]:
|
||||
paths: List[Path] = []
|
||||
for pattern in patterns:
|
||||
if not pattern:
|
||||
continue
|
||||
for path in sorted(Path().glob(pattern)):
|
||||
if path.is_file():
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
sboms = expand_globs(args.sboms)
|
||||
vexes = expand_globs(args.vex)
|
||||
manifest_extras = expand_globs(args.manifest_extra)
|
||||
output_dir = Path(args.output)
|
||||
|
||||
if not sboms or not vexes:
|
||||
raise SystemExit("No SBOM or VEX inputs found; supply --sboms/--vex globs")
|
||||
|
||||
scanners = load_scanners(Path(args.config))
|
||||
if not scanners:
|
||||
raise SystemExit("Scanner config has no entries")
|
||||
|
||||
results = run_bench(sboms, vexes, scanners, args.runs, args.shuffle, output_dir, manifest_extras)
|
||||
|
||||
results_csv = output_dir / "results.csv"
|
||||
write_results(results, results_csv)
|
||||
|
||||
manifest_inputs = sboms + vexes + [Path(args.config)] + (manifest_extras or [])
|
||||
write_inputs_manifest(manifest_inputs, output_dir / "inputs.sha256")
|
||||
|
||||
determinism = compute_determinism_rate(results)
|
||||
summary_path = output_dir / "summary.json"
|
||||
summary_path.write_text(json.dumps({"determinism_rate": determinism}, indent=2), encoding="utf-8")
|
||||
|
||||
print(f"Wrote {results_csv} (determinism_rate={determinism:.3f})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,61 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
# Allow direct import of run_bench from the harness folder
|
||||
HARNESS_DIR = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(HARNESS_DIR))
|
||||
|
||||
import run_bench # noqa: E402
|
||||
|
||||
|
||||
class DeterminismBenchTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.base = HARNESS_DIR
|
||||
self.sboms = [self.base / "inputs" / "sboms" / "sample-spdx.json"]
|
||||
self.vexes = [self.base / "inputs" / "vex" / "sample-openvex.json"]
|
||||
self.scanners = run_bench.load_scanners(self.base / "configs" / "scanners.json")
|
||||
|
||||
def test_canonical_and_shuffled_hashes_match(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
out_dir = Path(tmp)
|
||||
results = run_bench.run_bench(
|
||||
self.sboms,
|
||||
self.vexes,
|
||||
self.scanners,
|
||||
runs=3,
|
||||
shuffle=True,
|
||||
output_dir=out_dir,
|
||||
)
|
||||
rate = run_bench.compute_determinism_rate(results)
|
||||
self.assertAlmostEqual(rate, 1.0)
|
||||
|
||||
hashes = {(r["scanner"], r["mode"]): r["hash"] for r in results}
|
||||
self.assertEqual(len(hashes), 2)
|
||||
|
||||
def test_inputs_manifest_written(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
out_dir = Path(tmp)
|
||||
extra = Path(tmp) / "feeds.tar.gz"
|
||||
extra.write_bytes(b"feed")
|
||||
results = run_bench.run_bench(
|
||||
self.sboms,
|
||||
self.vexes,
|
||||
self.scanners,
|
||||
runs=1,
|
||||
shuffle=False,
|
||||
output_dir=out_dir,
|
||||
manifest_extras=[extra],
|
||||
)
|
||||
run_bench.write_results(results, out_dir / "results.csv")
|
||||
manifest = out_dir / "inputs.sha256"
|
||||
run_bench.write_inputs_manifest(self.sboms + self.vexes + [extra], manifest)
|
||||
text = manifest.read_text(encoding="utf-8")
|
||||
self.assertIn("sample-spdx.json", text)
|
||||
self.assertIn("sample-openvex.json", text)
|
||||
self.assertIn("feeds.tar.gz", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
5
src/Bench/StellaOps.Bench/TASKS.md
Normal file
5
src/Bench/StellaOps.Bench/TASKS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Tasks (Benchmarks Guild)
|
||||
|
||||
| ID | Status | Sprint | Notes | Evidence |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| BENCH-DETERMINISM-401-057 | DONE (2025-11-26) | SPRINT_0512_0001_0001_bench | Determinism harness and mock scanner added under `src/Bench/StellaOps.Bench/Determinism`; manifests + sample inputs included. | `src/Bench/StellaOps.Bench/Determinism/results` (generated) |
|
||||
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public NotifyChannelType? Type { get; init; }
|
||||
public string? Endpoint { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? SecretRef { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record EscalationPolicyUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
|
||||
public int? RepeatCount { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escalation level configuration.
|
||||
/// </summary>
|
||||
public sealed record EscalationLevelRequest
|
||||
{
|
||||
public int Order { get; init; }
|
||||
public TimeSpan EscalateAfter { get; init; }
|
||||
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escalation target configuration.
|
||||
/// </summary>
|
||||
public sealed record EscalationTargetRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? TargetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to start an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record StartEscalationRequest
|
||||
{
|
||||
public string? IncidentId { get; init; }
|
||||
public string? PolicyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge an escalation.
|
||||
/// </summary>
|
||||
public sealed record AcknowledgeEscalationRequest
|
||||
{
|
||||
public string? StateIdOrIncidentId { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an escalation.
|
||||
/// </summary>
|
||||
public sealed record ResolveEscalationRequest
|
||||
{
|
||||
public string? StateIdOrIncidentId { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record OnCallScheduleUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TimeZone { get; init; }
|
||||
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call layer configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallLayerRequest
|
||||
{
|
||||
public string? LayerId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public DateTimeOffset RotationStartsAt { get; init; }
|
||||
public TimeSpan RotationInterval { get; init; }
|
||||
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
|
||||
public OnCallRestrictionRequest? Restrictions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call participant configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallParticipantRequest
|
||||
{
|
||||
public string? UserId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method configuration.
|
||||
/// </summary>
|
||||
public sealed record ContactMethodRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Address { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call restriction configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallRestrictionRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time range for on-call restrictions.
|
||||
/// </summary>
|
||||
public sealed record TimeRangeRequest
|
||||
{
|
||||
public TimeOnly StartTime { get; init; }
|
||||
public TimeOnly EndTime { get; init; }
|
||||
public DayOfWeek? DayOfWeek { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add an on-call override.
|
||||
/// </summary>
|
||||
public sealed record OnCallOverrideRequest
|
||||
{
|
||||
public string? UserId { get; init; }
|
||||
public DateTimeOffset StartsAt { get; init; }
|
||||
public DateTimeOffset EndsAt { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve who is on-call.
|
||||
/// </summary>
|
||||
public sealed record OnCallResolveRequest
|
||||
{
|
||||
public DateTimeOffset? EvaluationTime { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update a localization bundle.
|
||||
/// </summary>
|
||||
public sealed record LocalizationBundleUpsertRequest
|
||||
{
|
||||
public string? Locale { get; init; }
|
||||
public string? BundleKey { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Strings { get; init; }
|
||||
public bool? IsDefault { get; init; }
|
||||
public string? ParentLocale { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve localized strings.
|
||||
/// </summary>
|
||||
public sealed record LocalizationResolveRequest
|
||||
{
|
||||
public string? BundleKey { get; init; }
|
||||
public IReadOnlyList<string>? StringKeys { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing resolved localized strings.
|
||||
/// </summary>
|
||||
public sealed record LocalizationResolveResponse
|
||||
{
|
||||
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
|
||||
public required string RequestedLocale { get; init; }
|
||||
public required IReadOnlyList<string> FallbackChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for a single localized string.
|
||||
/// </summary>
|
||||
public sealed record LocalizedStringResult
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
public required string ResolvedLocale { get; init; }
|
||||
public required bool UsedFallback { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a quiet hours schedule.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string CronExpression { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
public required string TimeZone { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a maintenance window.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceWindowUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required DateTimeOffset StartsAt { get; init; }
|
||||
public required DateTimeOffset EndsAt { get; init; }
|
||||
public bool? SuppressNotifications { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public ImmutableArray<string> ChannelIds { get; init; } = [];
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required TimeSpan DefaultWindow { get; init; }
|
||||
public int? MaxNotificationsPerWindow { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool? IsDefault { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideCreateRequest
|
||||
{
|
||||
public required string OverrideType { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a rule.
|
||||
/// </summary>
|
||||
public sealed record RuleUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public RuleMatchRequest? Match { get; init; }
|
||||
public IReadOnlyList<RuleActionRequest>? Actions { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match criteria for a rule.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchRequest
|
||||
{
|
||||
public string[]? EventKinds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action definition for a rule.
|
||||
/// </summary>
|
||||
public sealed record RuleActionRequest
|
||||
{
|
||||
public string? ActionId { get; init; }
|
||||
public string? Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to run a historical simulation against past events.
|
||||
/// </summary>
|
||||
public sealed class SimulationRunRequest
|
||||
{
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public ImmutableArray<string> EventKinds { get; init; } = [];
|
||||
public int MaxEvents { get; init; } = 1000;
|
||||
public bool IncludeNonMatches { get; init; } = true;
|
||||
public bool EvaluateThrottling { get; init; } = true;
|
||||
public bool EvaluateQuietHours { get; init; } = true;
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate a single event against current rules.
|
||||
/// </summary>
|
||||
public sealed class SimulateSingleEventRequest
|
||||
{
|
||||
public required JsonObject EventPayload { get; init; }
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a template.
|
||||
/// </summary>
|
||||
public sealed record TemplateUpsertRequest
|
||||
{
|
||||
public string? Key { get; init; }
|
||||
public string? Body { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public NotifyChannelType? ChannelType { get; init; }
|
||||
public NotifyTemplateRenderMode? RenderMode { get; init; }
|
||||
public NotifyDeliveryFormat? Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IEnumerable<KeyValuePair<string, string>>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for previewing a template render.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewRequest
|
||||
{
|
||||
public JsonNode? SamplePayload { get; init; }
|
||||
public bool? IncludeProvenance { get; init; }
|
||||
public string? ProvenanceBaseUrl { get; init; }
|
||||
public NotifyDeliveryFormat? FormatOverride { get; init; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
|
||||
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
|
||||
/// </summary>
|
||||
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
|
||||
private static readonly Regex EachBlockPattern = EachBlockRegex();
|
||||
private static readonly Regex IfBlockPattern = IfBlockRegex();
|
||||
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
|
||||
|
||||
private readonly ILogger<AdvancedTemplateRenderer> _logger;
|
||||
|
||||
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
|
||||
var body = template.Body;
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
options ??= new TemplateRenderOptions();
|
||||
|
||||
try
|
||||
{
|
||||
// Process conditional blocks first
|
||||
body = ProcessIfBlocks(body, payload);
|
||||
|
||||
// Process {{#each}} blocks
|
||||
body = ProcessEachBlocks(body, payload);
|
||||
|
||||
// Substitute simple placeholders
|
||||
body = SubstitutePlaceholders(body, payload);
|
||||
|
||||
// Convert to target format based on render mode
|
||||
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
|
||||
|
||||
// Append provenance link if requested
|
||||
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
|
||||
{
|
||||
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
|
||||
return $"[Render Error: {ex.Message}]";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProcessIfBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
// Process {{#if condition}}...{{else}}...{{/if}} blocks
|
||||
return IfBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var conditionPath = match.Groups[1].Value.Trim();
|
||||
var ifContent = match.Groups[2].Value;
|
||||
|
||||
var elseMatch = ElseBlockPattern.Match(ifContent);
|
||||
string trueContent;
|
||||
string falseContent;
|
||||
|
||||
if (elseMatch.Success)
|
||||
{
|
||||
trueContent = ifContent[..elseMatch.Index];
|
||||
falseContent = elseMatch.Groups[1].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
trueContent = ifContent;
|
||||
falseContent = string.Empty;
|
||||
}
|
||||
|
||||
var conditionValue = ResolvePath(payload, conditionPath);
|
||||
var isTruthy = EvaluateTruthy(conditionValue);
|
||||
|
||||
return isTruthy ? trueContent : falseContent;
|
||||
});
|
||||
}
|
||||
|
||||
private static bool EvaluateTruthy(JsonNode? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonValue jv when jv.TryGetValue(out bool b) => b,
|
||||
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
|
||||
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
|
||||
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
|
||||
JsonArray arr => arr.Count > 0,
|
||||
JsonObject obj => obj.Count > 0,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static string ProcessEachBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
return EachBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var collectionPath = match.Groups[1].Value.Trim();
|
||||
var innerTemplate = match.Groups[2].Value;
|
||||
|
||||
var collection = ResolvePath(payload, collectionPath);
|
||||
|
||||
if (collection is JsonArray arr)
|
||||
{
|
||||
var results = new List<string>();
|
||||
var index = 0;
|
||||
foreach (var item in arr)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@index}}", index.ToString())
|
||||
.Replace("{{this}}", item?.ToString() ?? string.Empty);
|
||||
|
||||
// Also substitute nested properties from item
|
||||
if (item is JsonObject itemObj)
|
||||
{
|
||||
itemResult = SubstitutePlaceholders(itemResult, itemObj);
|
||||
}
|
||||
|
||||
results.Add(itemResult);
|
||||
index++;
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
}
|
||||
|
||||
if (collection is JsonObject obj)
|
||||
{
|
||||
var results = new List<string>();
|
||||
foreach (var (key, value) in obj)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@key}}", key)
|
||||
.Replace("{{this}}", value?.ToString() ?? string.Empty);
|
||||
results.Add(itemResult);
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static string SubstitutePlaceholders(string body, JsonNode? payload)
|
||||
{
|
||||
return PlaceholderPattern.Replace(body, match =>
|
||||
{
|
||||
var path = match.Groups[1].Value.Trim();
|
||||
var resolved = ResolvePath(payload, path);
|
||||
return resolved?.ToString() ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonNode? ResolvePath(JsonNode? root, string path)
|
||||
{
|
||||
if (root is null || string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path.Split('.');
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
|
||||
{
|
||||
current = next;
|
||||
}
|
||||
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
|
||||
{
|
||||
current = arr[index];
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
|
||||
{
|
||||
// If source is already in the target format family, return as-is
|
||||
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
return targetFormat switch
|
||||
{
|
||||
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
|
||||
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
|
||||
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
|
||||
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
|
||||
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
|
||||
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Wrap content in a JSON structure
|
||||
var content = new JsonObject
|
||||
{
|
||||
["content"] = body,
|
||||
["format"] = sourceMode.ToString()
|
||||
};
|
||||
|
||||
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Convert Markdown to Slack mrkdwn format
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown)
|
||||
{
|
||||
// Slack uses similar markdown but with some differences
|
||||
// Convert **bold** to *bold* for Slack
|
||||
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Teams uses Adaptive Cards or MessageCard format
|
||||
// For simple conversion, wrap in basic card structure
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
|
||||
sourceMode == NotifyTemplateRenderMode.PlainText)
|
||||
{
|
||||
var card = new JsonObject
|
||||
{
|
||||
["@type"] = "MessageCard",
|
||||
["@context"] = "http://schema.org/extensions",
|
||||
["summary"] = "Notification",
|
||||
["sections"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["text"] = body
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown)
|
||||
{
|
||||
// Basic Markdown to HTML conversion for email
|
||||
return ConvertMarkdownToHtml(body);
|
||||
}
|
||||
|
||||
if (sourceMode == NotifyTemplateRenderMode.PlainText)
|
||||
{
|
||||
// Wrap plain text in basic HTML structure
|
||||
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertMarkdownToHtml(string markdown)
|
||||
{
|
||||
var html = new StringBuilder(markdown);
|
||||
|
||||
// Headers
|
||||
html.Replace("\n### ", "\n<h3>");
|
||||
html.Replace("\n## ", "\n<h2>");
|
||||
html.Replace("\n# ", "\n<h1>");
|
||||
|
||||
// Bold
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
|
||||
|
||||
// Italic
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
|
||||
|
||||
// Code
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
|
||||
|
||||
// Links
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
|
||||
|
||||
// Line breaks
|
||||
html.Replace("\n\n", "</p><p>");
|
||||
html.Replace("\n", "<br/>");
|
||||
|
||||
return $"<html><body><p>{html}</p></body></html>";
|
||||
}
|
||||
|
||||
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
|
||||
{
|
||||
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
|
||||
|
||||
return template.RenderMode switch
|
||||
{
|
||||
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
|
||||
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
|
||||
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex EachBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex IfBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex ElseBlockRegex();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
|
||||
/// </summary>
|
||||
public sealed class DefaultLocalizationResolver : ILocalizationResolver
|
||||
{
|
||||
private const string DefaultLocale = "en-us";
|
||||
private const string DefaultLanguage = "en";
|
||||
|
||||
private readonly INotifyLocalizationRepository _repository;
|
||||
private readonly ILogger<DefaultLocalizationResolver> _logger;
|
||||
|
||||
public DefaultLocalizationResolver(
|
||||
INotifyLocalizationRepository repository,
|
||||
ILogger<DefaultLocalizationResolver> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
var fallbackChain = BuildFallbackChain(locale);
|
||||
|
||||
foreach (var tryLocale in fallbackChain)
|
||||
{
|
||||
var bundle = await _repository.GetByKeyAndLocaleAsync(
|
||||
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = bundle.GetString(stringKey);
|
||||
if (value is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
|
||||
stringKey, bundleKey, tryLocale, locale);
|
||||
|
||||
return new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = tryLocale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try the default bundle
|
||||
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (defaultBundle is not null)
|
||||
{
|
||||
var value = defaultBundle.GetString(stringKey);
|
||||
if (value is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
|
||||
stringKey, bundleKey, defaultBundle.Locale);
|
||||
|
||||
return new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = defaultBundle.Locale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
|
||||
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentNullException.ThrowIfNull(stringKeys);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
var fallbackChain = BuildFallbackChain(locale);
|
||||
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
|
||||
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
|
||||
|
||||
// Load all bundles in the fallback chain
|
||||
var bundles = new List<NotifyLocalizationBundle>();
|
||||
foreach (var tryLocale in fallbackChain)
|
||||
{
|
||||
var bundle = await _repository.GetByKeyAndLocaleAsync(
|
||||
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is not null)
|
||||
{
|
||||
bundles.Add(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default bundle
|
||||
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
|
||||
{
|
||||
bundles.Add(defaultBundle);
|
||||
}
|
||||
|
||||
// Resolve each key through the bundles
|
||||
foreach (var key in keysToResolve)
|
||||
{
|
||||
foreach (var bundle in bundles)
|
||||
{
|
||||
var value = bundle.GetString(key);
|
||||
if (value is not null)
|
||||
{
|
||||
results[key] = new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = bundle.Locale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fallback chain for the given locale.
|
||||
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> BuildFallbackChain(string locale)
|
||||
{
|
||||
var chain = new List<string> { locale };
|
||||
|
||||
// Add language-only fallback (e.g., "pt" from "pt-br")
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
var languageOnly = locale[..dashIndex];
|
||||
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(languageOnly);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default locale if not already in chain
|
||||
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(DefaultLocale);
|
||||
}
|
||||
|
||||
// Add default language if not already in chain
|
||||
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(DefaultLanguage);
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static string NormalizeLocale(string? locale)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
return DefaultLocale;
|
||||
}
|
||||
|
||||
return locale.ToLowerInvariant().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Template renderer with support for render options, format conversion, and redaction.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a template with the given payload and options.
|
||||
/// </summary>
|
||||
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application-level service for managing versioned templates with localization support.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a template by key and locale, falling back to the default locale if not found.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
string locale,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific template by ID.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all templates for a tenant, optionally filtered.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
|
||||
string tenantId,
|
||||
string? keyPrefix = null,
|
||||
string? locale = null,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a template with version tracking.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate> UpsertAsync(
|
||||
NotifyTemplate template,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a template preview with sample payload (no persistence).
|
||||
/// </summary>
|
||||
Task<TemplatePreviewResult> PreviewAsync(
|
||||
NotifyTemplate template,
|
||||
JsonNode? samplePayload,
|
||||
TemplateRenderOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a template preview render.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewResult
|
||||
{
|
||||
public required string RenderedBody { get; init; }
|
||||
public required string? RenderedSubject { get; init; }
|
||||
public required NotifyTemplateRenderMode RenderMode { get; init; }
|
||||
public required NotifyDeliveryFormat Format { get; init; }
|
||||
public IReadOnlyList<string> RedactedFields { get; init; } = [];
|
||||
public string? ProvenanceLink { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for template rendering.
|
||||
/// </summary>
|
||||
public sealed record TemplateRenderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fields to redact from the output (dot-notation paths).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include provenance links in output.
|
||||
/// </summary>
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for provenance links.
|
||||
/// </summary>
|
||||
public string? ProvenanceBaseUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target format override.
|
||||
/// </summary>
|
||||
public NotifyDeliveryFormat? FormatOverride { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
|
||||
/// </summary>
|
||||
public sealed class NotifyTemplateService : INotifyTemplateService
|
||||
{
|
||||
private const string DefaultLocale = "en-us";
|
||||
|
||||
private readonly INotifyTemplateRepository _repository;
|
||||
private readonly INotifyTemplateRenderer _renderer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifyTemplateService> _logger;
|
||||
|
||||
public NotifyTemplateService(
|
||||
INotifyTemplateRepository repository,
|
||||
INotifyTemplateRenderer renderer,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifyTemplateService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
string locale,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
|
||||
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter by key
|
||||
var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Filter by channel type if specified
|
||||
if (channelType.HasValue)
|
||||
{
|
||||
matching = matching.Where(t => t.ChannelType == channelType.Value);
|
||||
}
|
||||
|
||||
var candidates = matching.ToArray();
|
||||
|
||||
// Try exact locale match
|
||||
var exactMatch = candidates.FirstOrDefault(t =>
|
||||
t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exactMatch is not null)
|
||||
{
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// Try language-only match (e.g., "en" from "en-us")
|
||||
var languageCode = locale.Split('-')[0];
|
||||
var languageMatch = candidates.FirstOrDefault(t =>
|
||||
t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (languageMatch is not null)
|
||||
{
|
||||
_logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.",
|
||||
key, locale, languageMatch.Locale);
|
||||
return languageMatch;
|
||||
}
|
||||
|
||||
// Fall back to default locale
|
||||
var defaultMatch = candidates.FirstOrDefault(t =>
|
||||
t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (defaultMatch is not null)
|
||||
{
|
||||
_logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.",
|
||||
key, locale);
|
||||
return defaultMatch;
|
||||
}
|
||||
|
||||
// Return any available template for the key
|
||||
return candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
|
||||
|
||||
return _repository.GetAsync(tenantId, templateId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(
|
||||
string tenantId,
|
||||
string? keyPrefix = null,
|
||||
string? locale = null,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IEnumerable<NotifyTemplate> filtered = allTemplates;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
var normalizedLocale = NormalizeLocale(locale);
|
||||
filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (channelType.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(t => t.ChannelType == channelType.Value);
|
||||
}
|
||||
|
||||
return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray();
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate> UpsertAsync(
|
||||
NotifyTemplate template,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for existing template to preserve creation metadata
|
||||
var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var updatedTemplate = NotifyTemplate.Create(
|
||||
templateId: template.TemplateId,
|
||||
tenantId: template.TenantId,
|
||||
channelType: template.ChannelType,
|
||||
key: template.Key,
|
||||
locale: template.Locale,
|
||||
body: template.Body,
|
||||
renderMode: template.RenderMode,
|
||||
format: template.Format,
|
||||
description: template.Description,
|
||||
metadata: template.Metadata,
|
||||
createdBy: existing?.CreatedBy ?? updatedBy,
|
||||
createdAt: existing?.CreatedAt ?? now,
|
||||
updatedBy: updatedBy,
|
||||
updatedAt: now);
|
||||
|
||||
await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.",
|
||||
updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy);
|
||||
|
||||
return updatedTemplate;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
|
||||
|
||||
await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId);
|
||||
}
|
||||
|
||||
public Task<TemplatePreviewResult> PreviewAsync(
|
||||
NotifyTemplate template,
|
||||
JsonNode? samplePayload,
|
||||
TemplateRenderOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
|
||||
options ??= new TemplateRenderOptions();
|
||||
|
||||
// Apply redaction to payload if allowlist is specified
|
||||
var redactedFields = new List<string>();
|
||||
var processedPayload = samplePayload;
|
||||
|
||||
if (options.RedactionAllowlist is { Count: > 0 })
|
||||
{
|
||||
processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields);
|
||||
}
|
||||
|
||||
// Render body
|
||||
var renderedBody = _renderer.Render(template, processedPayload, options);
|
||||
|
||||
// Render subject if present in metadata
|
||||
string? renderedSubject = null;
|
||||
if (template.Metadata.TryGetValue("subject", out var subjectTemplate))
|
||||
{
|
||||
var subjectTemplateObj = NotifyTemplate.Create(
|
||||
templateId: "subject-preview",
|
||||
tenantId: template.TenantId,
|
||||
channelType: template.ChannelType,
|
||||
key: "subject",
|
||||
locale: template.Locale,
|
||||
body: subjectTemplate);
|
||||
renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options);
|
||||
}
|
||||
|
||||
// Build provenance link if requested
|
||||
string? provenanceLink = null;
|
||||
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
|
||||
{
|
||||
provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
|
||||
}
|
||||
|
||||
var result = new TemplatePreviewResult
|
||||
{
|
||||
RenderedBody = renderedBody,
|
||||
RenderedSubject = renderedSubject,
|
||||
RenderMode = template.RenderMode,
|
||||
Format = options.FormatOverride ?? template.Format,
|
||||
RedactedFields = redactedFields,
|
||||
ProvenanceLink = provenanceLink
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet<string> allowlist, List<string> redactedFields)
|
||||
{
|
||||
if (payload is not JsonObject obj)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
foreach (var (key, value) in obj)
|
||||
{
|
||||
if (allowlist.Contains(key))
|
||||
{
|
||||
result[key] = value?.DeepClone();
|
||||
}
|
||||
else
|
||||
{
|
||||
result[key] = "[REDACTED]";
|
||||
redactedFields.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeLocale(string? locale)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,12 @@ public sealed class AttestationTemplateSeeder : IHostedService
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.Select(el => ToChannel(el, tenant))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
@@ -162,7 +162,7 @@ public sealed class AttestationTemplateSeeder : IHostedService
|
||||
description: "Seeded attestation routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
@@ -178,7 +178,7 @@ public sealed class AttestationTemplateSeeder : IHostedService
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
tenantId: tenantOverride,
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
|
||||
@@ -121,12 +121,12 @@ public sealed class RiskTemplateSeeder : IHostedService
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.Select(el => ToChannel(el, tenant))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
@@ -164,7 +164,7 @@ public sealed class RiskTemplateSeeder : IHostedService
|
||||
description: "Seeded risk routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
@@ -180,7 +180,7 @@ public sealed class RiskTemplateSeeder : IHostedService
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
tenantId: tenantOverride,
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for CLI-based notification delivery.
|
||||
/// Executes a configured command-line tool with notification payload as input.
|
||||
/// Useful for custom integrations and local testing.
|
||||
/// </summary>
|
||||
public sealed class CliChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<CliChannelAdapter> _logger;
|
||||
private readonly TimeSpan _commandTimeout;
|
||||
|
||||
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var command = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Parse command and arguments
|
||||
var (executable, arguments) = ParseCommand(command);
|
||||
if (string.IsNullOrWhiteSpace(executable))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build JSON payload to send via stdin
|
||||
var payload = new
|
||||
{
|
||||
bodyHash = rendered.BodyHash,
|
||||
channel = rendered.ChannelType.ToString(),
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
textBody = rendered.TextBody,
|
||||
format = rendered.Format.ToString(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
channelConfig = new
|
||||
{
|
||||
channelId = channel.ChannelId,
|
||||
name = channel.Name,
|
||||
properties = channel.Config?.Properties
|
||||
}
|
||||
};
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_commandTimeout);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Encoding.UTF8,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
// Add environment variables from channel config
|
||||
if (channel.Config?.Properties is not null)
|
||||
{
|
||||
foreach (var kv in channel.Config.Properties)
|
||||
{
|
||||
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var envVar = kv.Key[4..];
|
||||
startInfo.EnvironmentVariables[envVar] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Write payload to stdin
|
||||
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Read output streams
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var stdout = await outputTask.ConfigureAwait(false);
|
||||
var stderr = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"CLI command executed successfully. Exit code: 0. Output: {Output}",
|
||||
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
|
||||
|
||||
return ChannelDispatchResult.Ok(process.ExitCode);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
|
||||
process.ExitCode,
|
||||
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
|
||||
|
||||
// Non-zero exit codes are typically not retryable
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"Exit code {process.ExitCode}: {stderr}",
|
||||
process.ExitCode,
|
||||
shouldRetry: false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
|
||||
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string executable, string arguments) ParseCommand(string command)
|
||||
{
|
||||
command = command.Trim();
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return (string.Empty, string.Empty);
|
||||
|
||||
// Handle quoted executable paths
|
||||
if (command.StartsWith('"'))
|
||||
{
|
||||
var endQuote = command.IndexOf('"', 1);
|
||||
if (endQuote > 0)
|
||||
{
|
||||
var exe = command[1..endQuote];
|
||||
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
|
||||
return (exe, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple space-separated
|
||||
var spaceIndex = command.IndexOf(' ');
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
|
||||
}
|
||||
|
||||
return (command, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for email delivery. Requires SMTP configuration.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
|
||||
public EmailChannelAdapter(ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var target = channel.Config?.Target ?? rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return Task.FromResult(ChannelDispatchResult.Fail(
|
||||
"Email recipient not configured",
|
||||
shouldRetry: false));
|
||||
}
|
||||
|
||||
// Email delivery requires SMTP integration which depends on environment config.
|
||||
// For now, log the intent and return success for dev/test scenarios.
|
||||
// Production deployments should integrate with an SMTP relay or email service.
|
||||
_logger.LogInformation(
|
||||
"Email delivery queued: to={Recipient}, subject={Subject}, format={Format}",
|
||||
target,
|
||||
rendered.Title,
|
||||
rendered.Format);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Resolve SMTP settings from channel.Config.SecretRef
|
||||
// 2. Build and send the email via SmtpClient or a service like SendGrid
|
||||
// 3. Return actual success/failure based on delivery
|
||||
|
||||
return Task.FromResult(ChannelDispatchResult.Ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Sends rendered notifications through a specific channel type.
|
||||
/// </summary>
|
||||
public interface INotifyChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type this adapter handles.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a rendered notification through the channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">The channel configuration.</param>
|
||||
/// <param name="rendered">The rendered notification content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The dispatch result with status and any error details.</returns>
|
||||
Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public int? StatusCode { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public bool ShouldRetry { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = statusCode
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = statusCode,
|
||||
Reason = reason,
|
||||
ShouldRetry = shouldRetry
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app inbox notifications.
|
||||
/// Stores notifications in the database for users to retrieve via API or WebSocket.
|
||||
/// </summary>
|
||||
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly IInAppInboxStore _inboxStore;
|
||||
private readonly ILogger<InAppInboxChannelAdapter> _logger;
|
||||
|
||||
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
|
||||
{
|
||||
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var userId = rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
// Try to get from channel config
|
||||
userId = channel.Config?.Target;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false);
|
||||
}
|
||||
|
||||
var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId;
|
||||
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var inboxMessage = new InAppInboxMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Title = rendered.Title ?? "Notification",
|
||||
Body = rendered.Body ?? string.Empty,
|
||||
Summary = rendered.Summary,
|
||||
Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general",
|
||||
Priority = DeterminePriority(rendered),
|
||||
Metadata = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DetermineExpiry(channel),
|
||||
SourceChannel = channel.ChannelId,
|
||||
DeliveryId = messageId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app inbox message stored for user {UserId}. MessageId: {MessageId}",
|
||||
userId,
|
||||
inboxMessage.MessageId);
|
||||
|
||||
return ChannelDispatchResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Critical;
|
||||
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.High;
|
||||
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Normal;
|
||||
|
||||
return InAppInboxPriority.Low;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? DetermineExpiry(NotifyChannel channel)
|
||||
{
|
||||
var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl");
|
||||
if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours))
|
||||
{
|
||||
return DateTimeOffset.UtcNow.AddHours(ttlHours);
|
||||
}
|
||||
|
||||
// Default 30 day expiry
|
||||
return DateTimeOffset.UtcNow.AddDays(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for in-app inbox messages.
|
||||
/// </summary>
|
||||
public interface IInAppInboxStore
|
||||
{
|
||||
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
Task<InAppInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox message model.
|
||||
/// </summary>
|
||||
public sealed record InAppInboxMessage
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public InAppInboxPriority Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
public bool IsRead => ReadAt.HasValue;
|
||||
public string? SourceChannel { get; init; }
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority levels for in-app inbox messages.
|
||||
/// </summary>
|
||||
public enum InAppInboxPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that bridges IInAppInboxStore to INotifyInboxRepository.
|
||||
/// </summary>
|
||||
public sealed class MongoInboxStoreAdapter : IInAppInboxStore
|
||||
{
|
||||
private readonly INotifyInboxRepository _repository;
|
||||
|
||||
public MongoInboxStoreAdapter(INotifyInboxRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var repoMessage = new NotifyInboxMessage
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
TenantId = message.TenantId,
|
||||
UserId = message.UserId,
|
||||
Title = message.Title,
|
||||
Body = message.Body,
|
||||
Summary = message.Summary,
|
||||
Category = message.Category,
|
||||
Priority = (int)message.Priority,
|
||||
Metadata = message.Metadata,
|
||||
CreatedAt = message.CreatedAt,
|
||||
ExpiresAt = message.ExpiresAt,
|
||||
ReadAt = message.ReadAt,
|
||||
SourceChannel = message.SourceChannel,
|
||||
DeliveryId = message.DeliveryId
|
||||
};
|
||||
|
||||
await _repository.StoreAsync(repoMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoMessages = await _repository.GetForUserAsync(tenantId, userId, limit, cancellationToken).ConfigureAwait(false);
|
||||
return repoMessages.Select(MapToInboxMessage).ToList();
|
||||
}
|
||||
|
||||
public async Task<InAppInboxMessage?> GetAsync(
|
||||
string tenantId,
|
||||
string messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var repoMessage = await _repository.GetAsync(tenantId, messageId, cancellationToken).ConfigureAwait(false);
|
||||
return repoMessage is null ? null : MapToInboxMessage(repoMessage);
|
||||
}
|
||||
|
||||
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.MarkReadAsync(tenantId, messageId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.MarkAllReadAsync(tenantId, userId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.DeleteAsync(tenantId, messageId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.GetUnreadCountAsync(tenantId, userId, cancellationToken);
|
||||
}
|
||||
|
||||
private static InAppInboxMessage MapToInboxMessage(NotifyInboxMessage repo)
|
||||
{
|
||||
return new InAppInboxMessage
|
||||
{
|
||||
MessageId = repo.MessageId,
|
||||
TenantId = repo.TenantId,
|
||||
UserId = repo.UserId,
|
||||
Title = repo.Title,
|
||||
Body = repo.Body,
|
||||
Summary = repo.Summary,
|
||||
Category = repo.Category,
|
||||
Priority = (InAppInboxPriority)repo.Priority,
|
||||
Metadata = repo.Metadata,
|
||||
CreatedAt = repo.CreatedAt,
|
||||
ExpiresAt = repo.ExpiresAt,
|
||||
ReadAt = repo.ReadAt,
|
||||
SourceChannel = repo.SourceChannel,
|
||||
DeliveryId = repo.DeliveryId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for OpsGenie incident management integration.
|
||||
/// Uses the OpsGenie Alert API v2.
|
||||
/// </summary>
|
||||
public sealed class OpsGenieChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private const string DefaultOpsGenieApiUrl = "https://api.opsgenie.com/v2/alerts";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<OpsGenieChannelAdapter> _logger;
|
||||
|
||||
public OpsGenieChannelAdapter(HttpClient httpClient, ILogger<OpsGenieChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.OpsGenie;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
// OpsGenie API key should be stored via SecretRef (resolved externally)
|
||||
// or provided in Properties as "api_key"
|
||||
var apiKey = channel.Config?.Properties.GetValueOrDefault("api_key");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("OpsGenie API key not configured in properties", shouldRetry: false);
|
||||
}
|
||||
|
||||
var endpoint = channel.Config?.Endpoint ?? DefaultOpsGenieApiUrl;
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid OpsGenie endpoint: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build OpsGenie Alert API v2 payload
|
||||
var priority = DeterminePriority(rendered);
|
||||
var payload = new
|
||||
{
|
||||
message = rendered.Title ?? "StellaOps Notification",
|
||||
alias = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
|
||||
description = rendered.Body,
|
||||
priority = priority,
|
||||
source = "StellaOps Notifier",
|
||||
tags = new[] { "stellaops", "notification" },
|
||||
details = new Dictionary<string, string>
|
||||
{
|
||||
["channel"] = channel.ChannelId,
|
||||
["target"] = rendered.Target ?? string.Empty,
|
||||
["summary"] = rendered.Summary ?? string.Empty,
|
||||
["locale"] = rendered.Locale ?? "en-US"
|
||||
},
|
||||
entity = channel.Config?.Properties.GetValueOrDefault("entity") ?? string.Empty,
|
||||
note = $"Sent via StellaOps Notifier at {DateTimeOffset.UtcNow:O}"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("GenieKey", apiKey);
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"OpsGenie alert sent successfully to {Endpoint}. Status: {StatusCode}",
|
||||
endpoint,
|
||||
statusCode);
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"OpsGenie delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
|
||||
endpoint,
|
||||
statusCode,
|
||||
errorContent,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}: {errorContent}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "OpsGenie delivery to {Endpoint} failed with network error.", endpoint);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpsGenie delivery to {Endpoint} timed out.", endpoint);
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
// Map notification priority to OpsGenie priority (P1-P5)
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P1";
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P2";
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P3";
|
||||
if (rendered.Title?.Contains("info", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "P4";
|
||||
|
||||
return "P3"; // Default to medium priority
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for PagerDuty incident management integration.
|
||||
/// Uses the PagerDuty Events API v2 for incident creation and updates.
|
||||
/// </summary>
|
||||
public sealed class PagerDutyChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private const string DefaultPagerDutyApiUrl = "https://events.pagerduty.com/v2/enqueue";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<PagerDutyChannelAdapter> _logger;
|
||||
|
||||
public PagerDutyChannelAdapter(HttpClient httpClient, ILogger<PagerDutyChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.PagerDuty;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
// PagerDuty routing key should be stored via SecretRef (resolved externally)
|
||||
// or provided in Properties as "routing_key"
|
||||
var routingKey = channel.Config?.Properties.GetValueOrDefault("routing_key");
|
||||
if (string.IsNullOrWhiteSpace(routingKey))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("PagerDuty routing key not configured in properties", shouldRetry: false);
|
||||
}
|
||||
|
||||
var endpoint = channel.Config?.Endpoint ?? DefaultPagerDutyApiUrl;
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid PagerDuty endpoint: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build PagerDuty Events API v2 payload
|
||||
var severity = DetermineSeverity(rendered);
|
||||
var payload = new
|
||||
{
|
||||
routing_key = routingKey,
|
||||
event_action = "trigger",
|
||||
dedup_key = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
|
||||
payload = new
|
||||
{
|
||||
summary = rendered.Title ?? "StellaOps Notification",
|
||||
source = "StellaOps Notifier",
|
||||
severity = severity,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
custom_details = new
|
||||
{
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
channel = channel.ChannelId,
|
||||
target = rendered.Target
|
||||
}
|
||||
},
|
||||
client = "StellaOps",
|
||||
client_url = channel.Config?.Properties.GetValueOrDefault("client_url") ?? string.Empty
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"PagerDuty event sent successfully to {Endpoint}. Status: {StatusCode}",
|
||||
endpoint,
|
||||
statusCode);
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"PagerDuty delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
|
||||
endpoint,
|
||||
statusCode,
|
||||
errorContent,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}: {errorContent}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "PagerDuty delivery to {Endpoint} failed with network error.", endpoint);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PagerDuty delivery to {Endpoint} timed out.", endpoint);
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetermineSeverity(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
// Map notification priority to PagerDuty severity
|
||||
// Priority can be embedded in metadata or parsed from title
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "critical";
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "error";
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return "warning";
|
||||
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack webhook delivery.
|
||||
/// </summary>
|
||||
public sealed class SlackChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<SlackChannelAdapter> _logger;
|
||||
|
||||
public SlackChannelAdapter(HttpClient httpClient, ILogger<SlackChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Slack webhook URL not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid Slack webhook URL: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build Slack message payload
|
||||
var slackPayload = new
|
||||
{
|
||||
channel = channel.Config?.Target,
|
||||
text = rendered.Title,
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(slackPayload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Slack delivery to channel {Target} succeeded.",
|
||||
channel.Config?.Target ?? "(default)");
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Slack delivery failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slack delivery failed with network error.");
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Slack delivery timed out.");
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for webhook (HTTP POST) delivery with retry support.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
|
||||
public WebhookChannelAdapter(HttpClient httpClient, ILogger<WebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Webhook endpoint not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid webhook endpoint: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
channel = channel.ChannelId,
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
format = rendered.Format.ToString().ToLowerInvariant(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
// Add HMAC signature header if secret is available (placeholder for KMS integration)
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Webhook delivery to {Endpoint} succeeded with status {StatusCode}.",
|
||||
endpoint,
|
||||
statusCode);
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery to {Endpoint} failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
endpoint,
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Webhook delivery to {Endpoint} failed with network error.", endpoint);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Webhook delivery to {Endpoint} timed out.", endpoint);
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the correlation engine.
|
||||
/// </summary>
|
||||
public sealed class DefaultCorrelationEngine : ICorrelationEngine
|
||||
{
|
||||
private readonly ICorrelationKeyEvaluator _keyEvaluator;
|
||||
private readonly INotifyThrottler _throttler;
|
||||
private readonly IQuietHoursEvaluator _quietHoursEvaluator;
|
||||
private readonly CorrelationKeyConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultCorrelationEngine> _logger;
|
||||
|
||||
// In-memory incident store (in production, would use a repository)
|
||||
private readonly ConcurrentDictionary<string, NotifyIncident> _incidents = new();
|
||||
|
||||
public DefaultCorrelationEngine(
|
||||
ICorrelationKeyEvaluator keyEvaluator,
|
||||
INotifyThrottler throttler,
|
||||
IQuietHoursEvaluator quietHoursEvaluator,
|
||||
IOptions<CorrelationKeyConfig> config,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultCorrelationEngine> logger)
|
||||
{
|
||||
_keyEvaluator = keyEvaluator ?? throw new ArgumentNullException(nameof(keyEvaluator));
|
||||
_throttler = throttler ?? throw new ArgumentNullException(nameof(throttler));
|
||||
_quietHoursEvaluator = quietHoursEvaluator ?? throw new ArgumentNullException(nameof(quietHoursEvaluator));
|
||||
_config = config?.Value ?? new CorrelationKeyConfig();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CorrelationResult> ProcessAsync(
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
NotifyRuleAction action,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
var tenantId = @event.Tenant;
|
||||
|
||||
// 1. Check maintenance window
|
||||
var maintenanceResult = await _quietHoursEvaluator.IsInMaintenanceAsync(tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (maintenanceResult.IsInMaintenance)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} suppressed due to maintenance window: {Reason}",
|
||||
@event.EventId, maintenanceResult.MaintenanceReason);
|
||||
|
||||
return new CorrelationResult
|
||||
{
|
||||
Decision = CorrelationDecision.Maintenance,
|
||||
Reason = maintenanceResult.MaintenanceReason
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check quiet hours (per channel if action specifies)
|
||||
var quietHoursResult = await _quietHoursEvaluator.IsInQuietHoursAsync(
|
||||
tenantId, action.Channel, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (quietHoursResult.IsInQuietHours)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} suppressed due to quiet hours: {Reason}",
|
||||
@event.EventId, quietHoursResult.Reason);
|
||||
|
||||
return new CorrelationResult
|
||||
{
|
||||
Decision = CorrelationDecision.QuietHours,
|
||||
Reason = quietHoursResult.Reason,
|
||||
QuietHoursEndsAt = quietHoursResult.QuietHoursEndsAt
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Compute correlation key
|
||||
var correlationKey = _keyEvaluator.EvaluateDefaultKey(@event);
|
||||
|
||||
// 4. Get or create incident
|
||||
var (incident, isNew) = await GetOrCreateIncidentInternalAsync(
|
||||
tenantId, correlationKey, @event.Kind, @event, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 5. Check if incident is already acknowledged
|
||||
if (incident.Status == NotifyIncidentStatus.Acknowledged)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} suppressed - incident {IncidentId} already acknowledged",
|
||||
@event.EventId, incident.IncidentId);
|
||||
|
||||
return new CorrelationResult
|
||||
{
|
||||
Decision = CorrelationDecision.Acknowledged,
|
||||
Reason = "Incident already acknowledged",
|
||||
CorrelationKey = correlationKey,
|
||||
IncidentId = incident.IncidentId,
|
||||
IsNewIncident = false
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Check throttling (if action has throttle configured)
|
||||
if (action.Throttle is { } throttle && throttle > TimeSpan.Zero)
|
||||
{
|
||||
var throttleKey = $"{rule.RuleId}:{action.ActionId}:{correlationKey}";
|
||||
var isThrottled = await _throttler.IsThrottledAsync(
|
||||
tenantId, throttleKey, throttle, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (isThrottled)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} throttled: key={ThrottleKey}, window={Throttle}",
|
||||
@event.EventId, throttleKey, throttle);
|
||||
|
||||
return new CorrelationResult
|
||||
{
|
||||
Decision = CorrelationDecision.Throttled,
|
||||
Reason = $"Throttled for {throttle}",
|
||||
CorrelationKey = correlationKey,
|
||||
IncidentId = incident.IncidentId,
|
||||
IsNewIncident = isNew,
|
||||
ThrottledUntil = _timeProvider.GetUtcNow().Add(throttle)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 7. If this is a new event added to an existing incident within the correlation window,
|
||||
// and it's not the first event, suppress delivery (already notified)
|
||||
if (!isNew && incident.EventCount > 1)
|
||||
{
|
||||
var windowEnd = incident.FirstEventAt.Add(_config.CorrelationWindow);
|
||||
if (_timeProvider.GetUtcNow() < windowEnd)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} correlated to existing incident {IncidentId} within window",
|
||||
@event.EventId, incident.IncidentId);
|
||||
|
||||
return new CorrelationResult
|
||||
{
|
||||
Decision = CorrelationDecision.Correlated,
|
||||
Reason = "Event correlated to existing incident",
|
||||
CorrelationKey = correlationKey,
|
||||
IncidentId = incident.IncidentId,
|
||||
IsNewIncident = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Proceed with delivery
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} approved for delivery: incident={IncidentId}, isNew={IsNew}",
|
||||
@event.EventId, incident.IncidentId, isNew);
|
||||
|
||||
return new CorrelationResult
|
||||
{
|
||||
Decision = CorrelationDecision.Deliver,
|
||||
CorrelationKey = correlationKey,
|
||||
IncidentId = incident.IncidentId,
|
||||
IsNewIncident = isNew
|
||||
};
|
||||
}
|
||||
|
||||
public Task<NotifyIncident> GetOrCreateIncidentAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
string kind,
|
||||
NotifyEvent @event,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (incident, _) = GetOrCreateIncidentInternalAsync(
|
||||
tenantId, correlationKey, kind, @event, cancellationToken).GetAwaiter().GetResult();
|
||||
return Task.FromResult(incident);
|
||||
}
|
||||
|
||||
private Task<(NotifyIncident Incident, bool IsNew)> GetOrCreateIncidentInternalAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
string kind,
|
||||
NotifyEvent @event,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incidentKey = $"{tenantId}:{correlationKey}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check if existing incident is within correlation window
|
||||
if (_incidents.TryGetValue(incidentKey, out var existing))
|
||||
{
|
||||
var windowEnd = existing.FirstEventAt.Add(_config.CorrelationWindow);
|
||||
if (now < windowEnd && existing.Status == NotifyIncidentStatus.Open)
|
||||
{
|
||||
// Add event to existing incident
|
||||
var updated = existing with
|
||||
{
|
||||
EventCount = existing.EventCount + 1,
|
||||
LastEventAt = now,
|
||||
EventIds = existing.EventIds.Add(@event.EventId),
|
||||
UpdatedAt = now
|
||||
};
|
||||
_incidents[incidentKey] = updated;
|
||||
return Task.FromResult((updated, false));
|
||||
}
|
||||
}
|
||||
|
||||
// Create new incident
|
||||
var incident = new NotifyIncident
|
||||
{
|
||||
IncidentId = Guid.NewGuid().ToString("N"),
|
||||
TenantId = tenantId,
|
||||
CorrelationKey = correlationKey,
|
||||
Kind = kind,
|
||||
Status = NotifyIncidentStatus.Open,
|
||||
EventCount = 1,
|
||||
FirstEventAt = now,
|
||||
LastEventAt = now,
|
||||
EventIds = [@event.EventId],
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_incidents[incidentKey] = incident;
|
||||
return Task.FromResult((incident, true));
|
||||
}
|
||||
|
||||
public Task<NotifyIncident> AcknowledgeIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string acknowledgedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var incident = _incidents.Values.FirstOrDefault(i =>
|
||||
i.TenantId == tenantId && i.IncidentId == incidentId);
|
||||
|
||||
if (incident is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Incident {incidentId} not found");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = incident with
|
||||
{
|
||||
Status = NotifyIncidentStatus.Acknowledged,
|
||||
AcknowledgedAt = now,
|
||||
AcknowledgedBy = acknowledgedBy,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var key = $"{tenantId}:{incident.CorrelationKey}";
|
||||
_incidents[key] = updated;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Incident {IncidentId} acknowledged by {AcknowledgedBy}",
|
||||
incidentId, acknowledgedBy);
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
public Task<NotifyIncident> ResolveIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string resolvedBy,
|
||||
string? resolutionNote = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var incident = _incidents.Values.FirstOrDefault(i =>
|
||||
i.TenantId == tenantId && i.IncidentId == incidentId);
|
||||
|
||||
if (incident is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Incident {incidentId} not found");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = incident with
|
||||
{
|
||||
Status = NotifyIncidentStatus.Resolved,
|
||||
ResolvedAt = now,
|
||||
ResolvedBy = resolvedBy,
|
||||
ResolutionNote = resolutionNote,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var key = $"{tenantId}:{incident.CorrelationKey}";
|
||||
_incidents[key] = updated;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Incident {IncidentId} resolved by {ResolvedBy}: {ResolutionNote}",
|
||||
incidentId, resolvedBy, resolutionNote);
|
||||
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of correlation key evaluator using template expressions.
|
||||
/// </summary>
|
||||
public sealed partial class DefaultCorrelationKeyEvaluator : ICorrelationKeyEvaluator
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
|
||||
|
||||
private readonly CorrelationKeyConfig _config;
|
||||
|
||||
public DefaultCorrelationKeyEvaluator(IOptions<CorrelationKeyConfig> config)
|
||||
{
|
||||
_config = config?.Value ?? new CorrelationKeyConfig();
|
||||
}
|
||||
|
||||
public string EvaluateKey(NotifyEvent @event, string expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expression);
|
||||
|
||||
return PlaceholderPattern.Replace(expression, match =>
|
||||
{
|
||||
var path = match.Groups[1].Value.Trim();
|
||||
return ResolveValue(@event, path) ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
public string EvaluateDefaultKey(NotifyEvent @event)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
// Check for kind-specific expression
|
||||
var expression = _config.DefaultExpression;
|
||||
|
||||
foreach (var (kindPattern, kindExpression) in _config.KindExpressions)
|
||||
{
|
||||
if (MatchesKindPattern(@event.Kind, kindPattern))
|
||||
{
|
||||
expression = kindExpression;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return EvaluateKey(@event, expression);
|
||||
}
|
||||
|
||||
private static string? ResolveValue(NotifyEvent @event, string path)
|
||||
{
|
||||
// Built-in event properties
|
||||
return path.ToLowerInvariant() switch
|
||||
{
|
||||
"eventid" => @event.EventId.ToString(),
|
||||
"kind" => @event.Kind,
|
||||
"tenant" => @event.Tenant,
|
||||
"actor" => @event.Actor,
|
||||
"ts" => @event.Ts.ToString("o"),
|
||||
"version" => @event.Version,
|
||||
_ when path.StartsWith("payload.", StringComparison.OrdinalIgnoreCase) =>
|
||||
ResolvePayloadPath(@event.Payload, path[8..]),
|
||||
_ when path.StartsWith("attributes.", StringComparison.OrdinalIgnoreCase) =>
|
||||
ResolveAttributesPath(@event.Attributes, path[11..]),
|
||||
_ => ResolvePayloadPath(@event.Payload, path) // Fallback to payload
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolvePayloadPath(JsonNode? payload, string path)
|
||||
{
|
||||
if (payload is null || string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path.Split('.');
|
||||
var current = payload;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
|
||||
{
|
||||
current = next;
|
||||
}
|
||||
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
|
||||
{
|
||||
current = arr[index];
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current?.ToString();
|
||||
}
|
||||
|
||||
private static string? ResolveAttributesPath(IReadOnlyDictionary<string, string>? attributes, string key)
|
||||
{
|
||||
if (attributes is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return attributes.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static bool MatchesKindPattern(string kind, string pattern)
|
||||
{
|
||||
// Support wildcard patterns like "scan.*" or "attestation.*"
|
||||
if (pattern.EndsWith(".*", StringComparison.Ordinal))
|
||||
{
|
||||
var prefix = pattern[..^2];
|
||||
return kind.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return kind.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using Cronos;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of quiet hours evaluator using cron expressions.
|
||||
/// </summary>
|
||||
public sealed class DefaultQuietHoursEvaluator : IQuietHoursEvaluator
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultQuietHoursEvaluator> _logger;
|
||||
private readonly INotifyQuietHoursRepository? _quietHoursRepository;
|
||||
private readonly INotifyMaintenanceWindowRepository? _maintenanceWindowRepository;
|
||||
private readonly INotifyOperatorOverrideRepository? _operatorOverrideRepository;
|
||||
|
||||
// In-memory fallback for testing
|
||||
private readonly List<NotifyQuietHoursSchedule> _schedules = [];
|
||||
private readonly List<NotifyMaintenanceWindow> _maintenanceWindows = [];
|
||||
|
||||
public DefaultQuietHoursEvaluator(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultQuietHoursEvaluator> logger,
|
||||
INotifyQuietHoursRepository? quietHoursRepository = null,
|
||||
INotifyMaintenanceWindowRepository? maintenanceWindowRepository = null,
|
||||
INotifyOperatorOverrideRepository? operatorOverrideRepository = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_quietHoursRepository = quietHoursRepository;
|
||||
_maintenanceWindowRepository = maintenanceWindowRepository;
|
||||
_operatorOverrideRepository = operatorOverrideRepository;
|
||||
}
|
||||
|
||||
public async Task<QuietHoursCheckResult> IsInQuietHoursAsync(
|
||||
string tenantId,
|
||||
string? channelId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for active bypass override
|
||||
if (_operatorOverrideRepository is not null)
|
||||
{
|
||||
var overrides = await _operatorOverrideRepository.ListActiveAsync(
|
||||
tenantId, now, NotifyOverrideType.BypassQuietHours, channelId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (overrides.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Quiet hours bypassed by operator override for tenant {TenantId}: override={OverrideId}",
|
||||
tenantId, overrides[0].OverrideId);
|
||||
|
||||
return new QuietHoursCheckResult
|
||||
{
|
||||
IsInQuietHours = false,
|
||||
Reason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Find applicable schedules for this tenant
|
||||
IEnumerable<NotifyQuietHoursSchedule> applicableSchedules;
|
||||
if (_quietHoursRepository is not null)
|
||||
{
|
||||
var schedules = await _quietHoursRepository.ListEnabledAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
|
||||
applicableSchedules = schedules;
|
||||
}
|
||||
else
|
||||
{
|
||||
applicableSchedules = _schedules
|
||||
.Where(s => s.TenantId == tenantId && s.Enabled)
|
||||
.Where(s => channelId is null || s.ChannelId is null || s.ChannelId == channelId);
|
||||
}
|
||||
|
||||
foreach (var schedule in applicableSchedules)
|
||||
{
|
||||
if (IsInSchedule(schedule, now, out var endsAt))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Quiet hours active for tenant {TenantId}: schedule={ScheduleId}, endsAt={EndsAt}",
|
||||
tenantId, schedule.ScheduleId, endsAt);
|
||||
|
||||
return new QuietHoursCheckResult
|
||||
{
|
||||
IsInQuietHours = true,
|
||||
QuietHoursScheduleId = schedule.ScheduleId,
|
||||
QuietHoursEndsAt = endsAt,
|
||||
Reason = $"Quiet hours: {schedule.Name}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new QuietHoursCheckResult
|
||||
{
|
||||
IsInQuietHours = false
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<MaintenanceCheckResult> IsInMaintenanceAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for active bypass override
|
||||
if (_operatorOverrideRepository is not null)
|
||||
{
|
||||
var overrides = await _operatorOverrideRepository.ListActiveAsync(
|
||||
tenantId, now, NotifyOverrideType.BypassMaintenance, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (overrides.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Maintenance window bypassed by operator override for tenant {TenantId}: override={OverrideId}",
|
||||
tenantId, overrides[0].OverrideId);
|
||||
|
||||
return new MaintenanceCheckResult
|
||||
{
|
||||
IsInMaintenance = false,
|
||||
MaintenanceReason = $"Bypassed by operator override: {overrides[0].Reason ?? overrides[0].OverrideId}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Find active maintenance windows
|
||||
NotifyMaintenanceWindow? activeWindow;
|
||||
if (_maintenanceWindowRepository is not null)
|
||||
{
|
||||
var windows = await _maintenanceWindowRepository.GetActiveAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
activeWindow = windows.FirstOrDefault();
|
||||
}
|
||||
else
|
||||
{
|
||||
activeWindow = _maintenanceWindows
|
||||
.Where(w => w.TenantId == tenantId && w.SuppressNotifications)
|
||||
.FirstOrDefault(w => w.IsActiveAt(now));
|
||||
}
|
||||
|
||||
if (activeWindow is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Maintenance window active for tenant {TenantId}: window={WindowId}, endsAt={EndsAt}",
|
||||
tenantId, activeWindow.WindowId, activeWindow.EndsAt);
|
||||
|
||||
return new MaintenanceCheckResult
|
||||
{
|
||||
IsInMaintenance = true,
|
||||
MaintenanceWindowId = activeWindow.WindowId,
|
||||
MaintenanceEndsAt = activeWindow.EndsAt,
|
||||
MaintenanceReason = activeWindow.Reason
|
||||
};
|
||||
}
|
||||
|
||||
return new MaintenanceCheckResult
|
||||
{
|
||||
IsInMaintenance = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a quiet hours schedule (for configuration/testing).
|
||||
/// </summary>
|
||||
public void AddSchedule(NotifyQuietHoursSchedule schedule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
_schedules.Add(schedule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a maintenance window (for configuration/testing).
|
||||
/// </summary>
|
||||
public void AddMaintenanceWindow(NotifyMaintenanceWindow window)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(window);
|
||||
_maintenanceWindows.Add(window);
|
||||
}
|
||||
|
||||
private bool IsInSchedule(NotifyQuietHoursSchedule schedule, DateTimeOffset now, out DateTimeOffset? endsAt)
|
||||
{
|
||||
endsAt = null;
|
||||
|
||||
try
|
||||
{
|
||||
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZone);
|
||||
var localNow = TimeZoneInfo.ConvertTime(now, timeZone);
|
||||
|
||||
var cron = CronExpression.Parse(schedule.CronExpression);
|
||||
|
||||
// Look back for the most recent occurrence
|
||||
var searchStart = localNow.AddDays(-1);
|
||||
var lastOccurrence = cron.GetNextOccurrence(searchStart.DateTime, timeZone, inclusive: true);
|
||||
|
||||
if (lastOccurrence.HasValue)
|
||||
{
|
||||
var occurrenceOffset = new DateTimeOffset(lastOccurrence.Value, timeZone.GetUtcOffset(lastOccurrence.Value));
|
||||
var windowEnd = occurrenceOffset.Add(schedule.Duration);
|
||||
|
||||
if (now >= occurrenceOffset && now < windowEnd)
|
||||
{
|
||||
endsAt = windowEnd;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to evaluate quiet hours schedule {ScheduleId} for tenant {TenantId}",
|
||||
schedule.ScheduleId, schedule.TenantId);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Engine for correlating events, managing incidents, and applying throttling/quiet hours.
|
||||
/// </summary>
|
||||
public interface ICorrelationEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes an event through correlation, throttling, and quiet hours evaluation.
|
||||
/// </summary>
|
||||
/// <param name="event">The event to process.</param>
|
||||
/// <param name="rule">The matched rule.</param>
|
||||
/// <param name="action">The action to potentially execute.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The correlation result indicating whether to proceed with delivery.</returns>
|
||||
Task<CorrelationResult> ProcessAsync(
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
NotifyRuleAction action,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an incident for the given correlation key.
|
||||
/// </summary>
|
||||
Task<NotifyIncident> GetOrCreateIncidentAsync(
|
||||
string tenantId,
|
||||
string correlationKey,
|
||||
string kind,
|
||||
NotifyEvent @event,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an incident.
|
||||
/// </summary>
|
||||
Task<NotifyIncident> AcknowledgeIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string acknowledgedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an incident.
|
||||
/// </summary>
|
||||
Task<NotifyIncident> ResolveIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string resolvedBy,
|
||||
string? resolutionNote = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of correlation processing.
|
||||
/// </summary>
|
||||
public sealed record CorrelationResult
|
||||
{
|
||||
public required CorrelationDecision Decision { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public string? CorrelationKey { get; init; }
|
||||
public string? IncidentId { get; init; }
|
||||
public bool IsNewIncident { get; init; }
|
||||
public DateTimeOffset? ThrottledUntil { get; init; }
|
||||
public DateTimeOffset? QuietHoursEndsAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision made by the correlation engine.
|
||||
/// </summary>
|
||||
public enum CorrelationDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Proceed with delivery.
|
||||
/// </summary>
|
||||
Deliver,
|
||||
|
||||
/// <summary>
|
||||
/// Suppress due to throttling.
|
||||
/// </summary>
|
||||
Throttled,
|
||||
|
||||
/// <summary>
|
||||
/// Suppress due to quiet hours.
|
||||
/// </summary>
|
||||
QuietHours,
|
||||
|
||||
/// <summary>
|
||||
/// Suppress due to maintenance window.
|
||||
/// </summary>
|
||||
Maintenance,
|
||||
|
||||
/// <summary>
|
||||
/// Suppress and add to existing incident.
|
||||
/// </summary>
|
||||
Correlated,
|
||||
|
||||
/// <summary>
|
||||
/// Suppress due to incident already acknowledged.
|
||||
/// </summary>
|
||||
Acknowledged
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates correlation keys from event payloads using configurable expressions.
|
||||
/// </summary>
|
||||
public interface ICorrelationKeyEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts a correlation key from an event based on the configured expression.
|
||||
/// </summary>
|
||||
/// <param name="event">The event to correlate.</param>
|
||||
/// <param name="expression">The key expression (e.g., "kind:{{kind}}|target:{{payload.target}}").</param>
|
||||
/// <returns>The computed correlation key.</returns>
|
||||
string EvaluateKey(NotifyEvent @event, string expression);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a correlation key using the default expression for the event kind.
|
||||
/// </summary>
|
||||
string EvaluateDefaultKey(NotifyEvent @event);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for correlation key expressions per event kind.
|
||||
/// </summary>
|
||||
public sealed class CorrelationKeyConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Default expression used when no kind-specific expression is defined.
|
||||
/// </summary>
|
||||
public string DefaultExpression { get; set; } = "{{tenant}}:{{kind}}";
|
||||
|
||||
/// <summary>
|
||||
/// Kind-specific expressions (key = event kind pattern, value = expression).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> KindExpressions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Correlation window duration for grouping events.
|
||||
/// </summary>
|
||||
public TimeSpan CorrelationWindow { get; set; } = TimeSpan.FromMinutes(15);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Throttling service for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public interface INotifyThrottler
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a notification should be throttled based on the key and window.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="throttleKey">The unique key for throttling (e.g., action + correlation key).</param>
|
||||
/// <param name="window">The throttle window duration.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if throttled (should not send), false if allowed.</returns>
|
||||
Task<bool> IsThrottledAsync(
|
||||
string tenantId,
|
||||
string throttleKey,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a notification as sent, establishing the throttle marker.
|
||||
/// </summary>
|
||||
Task RecordSentAsync(
|
||||
string tenantId,
|
||||
string throttleKey,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a throttle check with additional context.
|
||||
/// </summary>
|
||||
public sealed record ThrottleCheckResult
|
||||
{
|
||||
public required bool IsThrottled { get; init; }
|
||||
public DateTimeOffset? ThrottledUntil { get; init; }
|
||||
public DateTimeOffset? LastSentAt { get; init; }
|
||||
public int SuppressedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether notifications should be suppressed due to quiet hours or maintenance windows.
|
||||
/// </summary>
|
||||
public interface IQuietHoursEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if the current time falls within a quiet hours period for the tenant.
|
||||
/// </summary>
|
||||
Task<QuietHoursCheckResult> IsInQuietHoursAsync(
|
||||
string tenantId,
|
||||
string? channelId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if notifications should be suppressed due to an active maintenance window.
|
||||
/// </summary>
|
||||
Task<MaintenanceCheckResult> IsInMaintenanceAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a quiet hours check.
|
||||
/// </summary>
|
||||
public sealed record QuietHoursCheckResult
|
||||
{
|
||||
public required bool IsInQuietHours { get; init; }
|
||||
public string? QuietHoursScheduleId { get; init; }
|
||||
public DateTimeOffset? QuietHoursEndsAt { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a maintenance window check.
|
||||
/// </summary>
|
||||
public sealed record MaintenanceCheckResult
|
||||
{
|
||||
public required bool IsInMaintenance { get; init; }
|
||||
public string? MaintenanceWindowId { get; init; }
|
||||
public DateTimeOffset? MaintenanceEndsAt { get; init; }
|
||||
public string? MaintenanceReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Throttler implementation using the lock repository for distributed throttling.
|
||||
/// </summary>
|
||||
public sealed class LockBasedThrottler : INotifyThrottler
|
||||
{
|
||||
private readonly INotifyLockRepository _lockRepository;
|
||||
private readonly ILogger<LockBasedThrottler> _logger;
|
||||
|
||||
public LockBasedThrottler(
|
||||
INotifyLockRepository lockRepository,
|
||||
ILogger<LockBasedThrottler> logger)
|
||||
{
|
||||
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> IsThrottledAsync(
|
||||
string tenantId,
|
||||
string throttleKey,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(throttleKey);
|
||||
|
||||
if (window <= TimeSpan.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lockKey = BuildThrottleKey(throttleKey);
|
||||
|
||||
// Try to acquire the lock - if we can't, it means we're throttled
|
||||
var acquired = await _lockRepository.TryAcquireAsync(
|
||||
tenantId,
|
||||
lockKey,
|
||||
"throttle",
|
||||
window,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!acquired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Notification throttled: tenant={TenantId}, key={ThrottleKey}, window={Window}",
|
||||
tenantId, throttleKey, window);
|
||||
return true;
|
||||
}
|
||||
|
||||
// We acquired the lock, so we're not throttled
|
||||
// Note: The lock will automatically expire after the window
|
||||
return false;
|
||||
}
|
||||
|
||||
public Task RecordSentAsync(
|
||||
string tenantId,
|
||||
string throttleKey,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The lock was already acquired in IsThrottledAsync, which also serves as the marker
|
||||
// This method exists for cases where throttle check and send are separate operations
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BuildThrottleKey(string key)
|
||||
{
|
||||
return $"throttle|{key}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a correlated incident grouping multiple related events.
|
||||
/// </summary>
|
||||
public sealed record NotifyIncident
|
||||
{
|
||||
public required string IncidentId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string CorrelationKey { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required NotifyIncidentStatus Status { get; init; }
|
||||
public required int EventCount { get; init; }
|
||||
public required DateTimeOffset FirstEventAt { get; init; }
|
||||
public required DateTimeOffset LastEventAt { get; init; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public string? ResolutionNote { get; init; }
|
||||
public ImmutableArray<Guid> EventIds { get; init; } = [];
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an incident through its lifecycle.
|
||||
/// </summary>
|
||||
public enum NotifyIncidentStatus
|
||||
{
|
||||
Open,
|
||||
Acknowledged,
|
||||
Resolved,
|
||||
Suppressed
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the digest generator.
|
||||
/// </summary>
|
||||
public sealed class DefaultDigestGenerator : IDigestGenerator
|
||||
{
|
||||
private readonly INotifyDeliveryRepository _deliveryRepository;
|
||||
private readonly INotifyTemplateRepository _templateRepository;
|
||||
private readonly INotifyTemplateRenderer _templateRenderer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultDigestGenerator> _logger;
|
||||
|
||||
public DefaultDigestGenerator(
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyTemplateRepository templateRepository,
|
||||
INotifyTemplateRenderer templateRenderer,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultDigestGenerator> logger)
|
||||
{
|
||||
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
|
||||
_templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository));
|
||||
_templateRenderer = templateRenderer ?? throw new ArgumentNullException(nameof(templateRenderer));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<NotifyDigest> GenerateAsync(
|
||||
DigestSchedule schedule,
|
||||
DateTimeOffset periodStart,
|
||||
DateTimeOffset periodEnd,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Generating digest for schedule {ScheduleId}: period {PeriodStart} to {PeriodEnd}",
|
||||
schedule.ScheduleId, periodStart, periodEnd);
|
||||
|
||||
// Query deliveries for the period
|
||||
var result = await _deliveryRepository.QueryAsync(
|
||||
tenantId: schedule.TenantId,
|
||||
since: periodStart,
|
||||
status: null, // All statuses
|
||||
limit: 1000,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter to relevant event kinds if specified
|
||||
var deliveries = result.Items.AsEnumerable();
|
||||
if (!schedule.EventKinds.IsDefaultOrEmpty)
|
||||
{
|
||||
var kindSet = schedule.EventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
deliveries = deliveries.Where(d => kindSet.Contains(d.Kind));
|
||||
}
|
||||
|
||||
// Filter to period
|
||||
deliveries = deliveries.Where(d =>
|
||||
d.CreatedAt >= periodStart && d.CreatedAt < periodEnd);
|
||||
|
||||
var deliveryList = deliveries.ToList();
|
||||
|
||||
// Compute event kind counts
|
||||
var kindCounts = deliveryList
|
||||
.GroupBy(d => d.Kind, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableDictionary(
|
||||
g => g.Key,
|
||||
g => g.Count(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var eventIds = deliveryList
|
||||
.Select(d => d.EventId)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var digest = new NotifyDigest
|
||||
{
|
||||
DigestId = Guid.NewGuid().ToString("N"),
|
||||
TenantId = schedule.TenantId,
|
||||
DigestKey = schedule.DigestKey,
|
||||
ScheduleId = schedule.ScheduleId,
|
||||
Period = schedule.Period,
|
||||
EventCount = deliveryList.Count,
|
||||
EventIds = eventIds,
|
||||
EventKindCounts = kindCounts,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
GeneratedAt = now,
|
||||
Status = deliveryList.Count > 0 ? NotifyDigestStatus.Ready : NotifyDigestStatus.Skipped,
|
||||
Metadata = schedule.Metadata
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated digest {DigestId} for schedule {ScheduleId}: {EventCount} events, {UniqueEvents} unique, {KindCount} kinds",
|
||||
digest.DigestId, schedule.ScheduleId, deliveryList.Count, eventIds.Length, kindCounts.Count);
|
||||
|
||||
return digest;
|
||||
}
|
||||
|
||||
public async Task<string> FormatAsync(
|
||||
NotifyDigest digest,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(digest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
|
||||
|
||||
var template = await _templateRepository.GetAsync(
|
||||
digest.TenantId, templateId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (template is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Digest template {TemplateId} not found for tenant {TenantId}",
|
||||
templateId, digest.TenantId);
|
||||
|
||||
return FormatDefaultDigest(digest);
|
||||
}
|
||||
|
||||
var payload = BuildDigestPayload(digest);
|
||||
return _templateRenderer.Render(template, payload);
|
||||
}
|
||||
|
||||
private static JsonObject BuildDigestPayload(NotifyDigest digest)
|
||||
{
|
||||
var kindCountsArray = new JsonArray();
|
||||
foreach (var (kind, count) in digest.EventKindCounts)
|
||||
{
|
||||
kindCountsArray.Add(new JsonObject
|
||||
{
|
||||
["kind"] = kind,
|
||||
["count"] = count
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["digestId"] = digest.DigestId,
|
||||
["tenantId"] = digest.TenantId,
|
||||
["digestKey"] = digest.DigestKey,
|
||||
["scheduleId"] = digest.ScheduleId,
|
||||
["period"] = digest.Period.ToString(),
|
||||
["eventCount"] = digest.EventCount,
|
||||
["uniqueEventCount"] = digest.EventIds.Length,
|
||||
["kindCounts"] = kindCountsArray,
|
||||
["periodStart"] = digest.PeriodStart.ToString("o"),
|
||||
["periodEnd"] = digest.PeriodEnd.ToString("o"),
|
||||
["generatedAt"] = digest.GeneratedAt.ToString("o")
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatDefaultDigest(NotifyDigest digest)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"## Notification Digest");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**Period:** {digest.PeriodStart:g} to {digest.PeriodEnd:g}");
|
||||
sb.AppendLine($"**Total Events:** {digest.EventCount}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (digest.EventKindCounts.Count > 0)
|
||||
{
|
||||
sb.AppendLine("### Event Summary");
|
||||
sb.AppendLine();
|
||||
foreach (var (kind, count) in digest.EventKindCounts.OrderByDescending(kv => kv.Value))
|
||||
{
|
||||
sb.AppendLine($"- **{kind}**: {count}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("*No events in this period.*");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Cronos;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the digest schedule runner.
|
||||
/// </summary>
|
||||
public sealed class DigestScheduleRunner : IDigestScheduleRunner
|
||||
{
|
||||
private readonly IDigestGenerator _digestGenerator;
|
||||
private readonly INotifyChannelRepository _channelRepository;
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> _channelAdapters;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DigestScheduleRunner> _logger;
|
||||
|
||||
// In-memory schedule store (in production, would use a repository)
|
||||
private readonly ConcurrentDictionary<string, DigestSchedule> _schedules = new();
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _lastRunTimes = new();
|
||||
|
||||
public DigestScheduleRunner(
|
||||
IDigestGenerator digestGenerator,
|
||||
INotifyChannelRepository channelRepository,
|
||||
IEnumerable<INotifyChannelAdapter> channelAdapters,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DigestScheduleRunner> logger)
|
||||
{
|
||||
_digestGenerator = digestGenerator ?? throw new ArgumentNullException(nameof(digestGenerator));
|
||||
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
|
||||
_channelAdapters = BuildAdapterMap(channelAdapters);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<int> ProcessDueDigestsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var processed = 0;
|
||||
|
||||
foreach (var schedule in _schedules.Values.Where(s => s.Enabled))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
if (IsDue(schedule, now))
|
||||
{
|
||||
await ProcessScheduleAsync(schedule, now, cancellationToken).ConfigureAwait(false);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to process digest schedule {ScheduleId} for tenant {TenantId}",
|
||||
schedule.ScheduleId, schedule.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
public DateTimeOffset? GetNextScheduledTime(DigestSchedule schedule, DateTimeOffset? after = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
|
||||
var referenceTime = after ?? _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZone);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(schedule.CronExpression))
|
||||
{
|
||||
var cron = CronExpression.Parse(schedule.CronExpression);
|
||||
var next = cron.GetNextOccurrence(referenceTime.UtcDateTime, timeZone);
|
||||
return next.HasValue
|
||||
? new DateTimeOffset(next.Value, timeZone.GetUtcOffset(next.Value))
|
||||
: null;
|
||||
}
|
||||
|
||||
// Default period-based scheduling
|
||||
return schedule.Period switch
|
||||
{
|
||||
DigestPeriod.Hourly => referenceTime.AddHours(1).Date.AddHours(referenceTime.Hour + 1),
|
||||
DigestPeriod.Daily => referenceTime.Date.AddDays(1).AddHours(9), // 9 AM next day
|
||||
DigestPeriod.Weekly => GetNextWeekday(referenceTime, DayOfWeek.Monday).AddHours(9),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to calculate next scheduled time for {ScheduleId}",
|
||||
schedule.ScheduleId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a digest schedule.
|
||||
/// </summary>
|
||||
public void RegisterSchedule(DigestSchedule schedule)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
_schedules[schedule.ScheduleId] = schedule;
|
||||
_logger.LogInformation(
|
||||
"Registered digest schedule {ScheduleId} for tenant {TenantId}",
|
||||
schedule.ScheduleId, schedule.TenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a digest schedule.
|
||||
/// </summary>
|
||||
public void UnregisterSchedule(string scheduleId)
|
||||
{
|
||||
_schedules.TryRemove(scheduleId, out _);
|
||||
_lastRunTimes.TryRemove(scheduleId, out _);
|
||||
}
|
||||
|
||||
private bool IsDue(DigestSchedule schedule, DateTimeOffset now)
|
||||
{
|
||||
// Check if we've run recently
|
||||
if (_lastRunTimes.TryGetValue(schedule.ScheduleId, out var lastRun))
|
||||
{
|
||||
var minInterval = schedule.Period switch
|
||||
{
|
||||
DigestPeriod.Hourly => TimeSpan.FromMinutes(55),
|
||||
DigestPeriod.Daily => TimeSpan.FromHours(23),
|
||||
DigestPeriod.Weekly => TimeSpan.FromDays(6.5),
|
||||
_ => TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
if (now - lastRun < minInterval)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var nextScheduled = GetNextScheduledTime(schedule, _lastRunTimes.GetValueOrDefault(schedule.ScheduleId));
|
||||
return nextScheduled.HasValue && now >= nextScheduled.Value;
|
||||
}
|
||||
|
||||
private async Task ProcessScheduleAsync(
|
||||
DigestSchedule schedule,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Processing digest schedule {ScheduleId}", schedule.ScheduleId);
|
||||
|
||||
// Calculate period
|
||||
var (periodStart, periodEnd) = CalculatePeriod(schedule, now);
|
||||
|
||||
// Generate digest
|
||||
var digest = await _digestGenerator.GenerateAsync(
|
||||
schedule, periodStart, periodEnd, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Record run time
|
||||
_lastRunTimes[schedule.ScheduleId] = now;
|
||||
|
||||
// Skip if no events
|
||||
if (digest.Status == NotifyDigestStatus.Skipped || digest.EventCount == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping empty digest {DigestId} for schedule {ScheduleId}",
|
||||
digest.DigestId, schedule.ScheduleId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format content
|
||||
var content = await _digestGenerator.FormatAsync(
|
||||
digest, schedule.TemplateId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get channel and send
|
||||
var channel = await _channelRepository.GetAsync(
|
||||
schedule.TenantId, schedule.ChannelId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Channel {ChannelId} not found for digest schedule {ScheduleId}",
|
||||
schedule.ChannelId, schedule.ScheduleId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_channelAdapters.TryGetValue(channel.Type, out var adapter))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No adapter found for channel type {ChannelType}",
|
||||
channel.Type);
|
||||
return;
|
||||
}
|
||||
|
||||
var rendered = NotifyDeliveryRendered.Create(
|
||||
channelType: channel.Type,
|
||||
format: NotifyDeliveryFormat.Json,
|
||||
target: channel.Config?.Target ?? string.Empty,
|
||||
title: $"Notification Digest: {schedule.Name}",
|
||||
body: content,
|
||||
locale: "en-us");
|
||||
|
||||
var result = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Sent digest {DigestId} via channel {ChannelId}: {EventCount} events",
|
||||
digest.DigestId, schedule.ChannelId, digest.EventCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to send digest {DigestId}: {Reason}",
|
||||
digest.DigestId, result.Reason);
|
||||
}
|
||||
}
|
||||
|
||||
private static (DateTimeOffset Start, DateTimeOffset End) CalculatePeriod(
|
||||
DigestSchedule schedule,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
return schedule.Period switch
|
||||
{
|
||||
DigestPeriod.Hourly => (now.AddHours(-1), now),
|
||||
DigestPeriod.Daily => (now.Date.AddDays(-1), now.Date),
|
||||
DigestPeriod.Weekly => (now.Date.AddDays(-7), now.Date),
|
||||
_ => (now.AddHours(-1), now)
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset GetNextWeekday(DateTimeOffset from, DayOfWeek target)
|
||||
{
|
||||
var daysUntil = ((int)target - (int)from.DayOfWeek + 7) % 7;
|
||||
if (daysUntil == 0) daysUntil = 7;
|
||||
return from.Date.AddDays(daysUntil);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> BuildAdapterMap(
|
||||
IEnumerable<INotifyChannelAdapter> adapters)
|
||||
{
|
||||
var builder = new Dictionary<NotifyChannelType, INotifyChannelAdapter>();
|
||||
foreach (var adapter in adapters)
|
||||
{
|
||||
builder[adapter.ChannelType] = adapter;
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Generates notification digests from accumulated events.
|
||||
/// </summary>
|
||||
public interface IDigestGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a digest for the given schedule and time period.
|
||||
/// </summary>
|
||||
Task<NotifyDigest> GenerateAsync(
|
||||
DigestSchedule schedule,
|
||||
DateTimeOffset periodStart,
|
||||
DateTimeOffset periodEnd,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Formats a digest into renderable content using the specified template.
|
||||
/// </summary>
|
||||
Task<string> FormatAsync(
|
||||
NotifyDigest digest,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages digest schedule execution and delivery.
|
||||
/// </summary>
|
||||
public interface IDigestScheduleRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks all schedules and generates/sends digests that are due.
|
||||
/// </summary>
|
||||
Task<int> ProcessDueDigestsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next scheduled time for a digest.
|
||||
/// </summary>
|
||||
DateTimeOffset? GetNextScheduledTime(DigestSchedule schedule, DateTimeOffset? after = null);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Digest;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a compiled digest summarizing multiple events for batch delivery.
|
||||
/// </summary>
|
||||
public sealed record NotifyDigest
|
||||
{
|
||||
public required string DigestId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string DigestKey { get; init; }
|
||||
public required string ScheduleId { get; init; }
|
||||
public required DigestPeriod Period { get; init; }
|
||||
public required int EventCount { get; init; }
|
||||
public required ImmutableArray<Guid> EventIds { get; init; }
|
||||
public required ImmutableDictionary<string, int> EventKindCounts { get; init; }
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public NotifyDigestStatus Status { get; init; } = NotifyDigestStatus.Pending;
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
public string? RenderedContent { get; init; }
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a digest through its lifecycle.
|
||||
/// </summary>
|
||||
public enum NotifyDigestStatus
|
||||
{
|
||||
Pending,
|
||||
Generating,
|
||||
Ready,
|
||||
Sent,
|
||||
Failed,
|
||||
Skipped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Digest delivery period/frequency.
|
||||
/// </summary>
|
||||
public enum DigestPeriod
|
||||
{
|
||||
Hourly,
|
||||
Daily,
|
||||
Weekly,
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a digest schedule.
|
||||
/// </summary>
|
||||
public sealed record DigestSchedule
|
||||
{
|
||||
public required string ScheduleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string DigestKey { get; init; }
|
||||
public required DigestPeriod Period { get; init; }
|
||||
public string? CronExpression { get; init; }
|
||||
public required string TimeZone { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string TemplateId { get; init; }
|
||||
public ImmutableArray<string> EventKinds { get; init; } = [];
|
||||
public bool Enabled { get; init; } = true;
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the escalation engine.
|
||||
/// </summary>
|
||||
public sealed class DefaultEscalationEngine : IEscalationEngine
|
||||
{
|
||||
private readonly INotifyEscalationPolicyRepository _policyRepository;
|
||||
private readonly INotifyEscalationStateRepository _stateRepository;
|
||||
private readonly INotifyChannelRepository _channelRepository;
|
||||
private readonly IOnCallResolver _onCallResolver;
|
||||
private readonly IEnumerable<INotifyChannelAdapter> _channelAdapters;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultEscalationEngine> _logger;
|
||||
|
||||
public DefaultEscalationEngine(
|
||||
INotifyEscalationPolicyRepository policyRepository,
|
||||
INotifyEscalationStateRepository stateRepository,
|
||||
INotifyChannelRepository channelRepository,
|
||||
IOnCallResolver onCallResolver,
|
||||
IEnumerable<INotifyChannelAdapter> channelAdapters,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultEscalationEngine> logger)
|
||||
{
|
||||
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
|
||||
_stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository));
|
||||
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
|
||||
_onCallResolver = onCallResolver ?? throw new ArgumentNullException(nameof(onCallResolver));
|
||||
_channelAdapters = channelAdapters ?? throw new ArgumentNullException(nameof(channelAdapters));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationState> StartEscalationAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(incidentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
// Check if escalation already exists for this incident
|
||||
var existingState = await _stateRepository.GetByIncidentAsync(tenantId, incidentId, cancellationToken).ConfigureAwait(false);
|
||||
if (existingState is not null && existingState.Status == NotifyEscalationStatus.Active)
|
||||
{
|
||||
_logger.LogDebug("Escalation already active for incident {IncidentId}", incidentId);
|
||||
return existingState;
|
||||
}
|
||||
|
||||
var policy = await _policyRepository.GetAsync(tenantId, policyId, cancellationToken).ConfigureAwait(false);
|
||||
if (policy is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Escalation policy {policyId} not found.");
|
||||
}
|
||||
|
||||
if (!policy.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException($"Escalation policy {policyId} is disabled.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var firstLevel = policy.Levels.FirstOrDefault();
|
||||
var nextEscalationAt = firstLevel is not null ? now.Add(firstLevel.EscalateAfter) : (DateTimeOffset?)null;
|
||||
|
||||
var state = NotifyEscalationState.Create(
|
||||
stateId: Guid.NewGuid().ToString("N"),
|
||||
tenantId: tenantId,
|
||||
incidentId: incidentId,
|
||||
policyId: policyId,
|
||||
currentLevel: 0,
|
||||
repeatIteration: 0,
|
||||
status: NotifyEscalationStatus.Active,
|
||||
nextEscalationAt: nextEscalationAt,
|
||||
createdAt: now);
|
||||
|
||||
await _stateRepository.UpsertAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Notify first level immediately
|
||||
if (firstLevel is not null)
|
||||
{
|
||||
await NotifyLevelAsync(tenantId, state, policy, firstLevel, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Started escalation {StateId} for incident {IncidentId} with policy {PolicyId}",
|
||||
state.StateId, incidentId, policyId);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<EscalationProcessResult> ProcessPendingEscalationsAsync(
|
||||
string tenantId,
|
||||
int batchSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var pendingStates = await _stateRepository.ListDueForEscalationAsync(tenantId, now, batchSize, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var processed = 0;
|
||||
var escalated = 0;
|
||||
var exhausted = 0;
|
||||
var errors = 0;
|
||||
var errorMessages = new List<string>();
|
||||
|
||||
foreach (var state in pendingStates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var policy = await _policyRepository.GetAsync(tenantId, state.PolicyId, cancellationToken).ConfigureAwait(false);
|
||||
if (policy is null || !policy.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Policy {PolicyId} not found or disabled for escalation {StateId}", state.PolicyId, state.StateId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = await ProcessEscalationAsync(tenantId, state, policy, now, cancellationToken).ConfigureAwait(false);
|
||||
processed++;
|
||||
|
||||
if (result.Escalated)
|
||||
{
|
||||
escalated++;
|
||||
}
|
||||
else if (result.Exhausted)
|
||||
{
|
||||
exhausted++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors++;
|
||||
errorMessages.Add($"State {state.StateId}: {ex.Message}");
|
||||
_logger.LogError(ex, "Error processing escalation {StateId}", state.StateId);
|
||||
}
|
||||
}
|
||||
|
||||
return new EscalationProcessResult
|
||||
{
|
||||
Processed = processed,
|
||||
Escalated = escalated,
|
||||
Exhausted = exhausted,
|
||||
Errors = errors,
|
||||
ErrorMessages = errorMessages.Count > 0 ? errorMessages : null
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationState?> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string stateIdOrIncidentId,
|
||||
string acknowledgedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await FindStateAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.Status != NotifyEscalationStatus.Active)
|
||||
{
|
||||
_logger.LogDebug("Escalation {StateId} is not active, cannot acknowledge", state.StateId);
|
||||
return state;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await _stateRepository.AcknowledgeAsync(tenantId, state.StateId, acknowledgedBy, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Escalation {StateId} acknowledged by {AcknowledgedBy}",
|
||||
state.StateId, acknowledgedBy);
|
||||
|
||||
return await _stateRepository.GetAsync(tenantId, state.StateId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationState?> ResolveAsync(
|
||||
string tenantId,
|
||||
string stateIdOrIncidentId,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await FindStateAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.Status == NotifyEscalationStatus.Resolved)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await _stateRepository.ResolveAsync(tenantId, state.StateId, resolvedBy, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Escalation {StateId} resolved by {ResolvedBy}",
|
||||
state.StateId, resolvedBy);
|
||||
|
||||
return await _stateRepository.GetAsync(tenantId, state.StateId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyEscalationState?> GetStateForIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _stateRepository.GetByIncidentAsync(tenantId, incidentId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<NotifyEscalationState?> FindStateAsync(
|
||||
string tenantId,
|
||||
string stateIdOrIncidentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Try by state ID first
|
||||
var state = await _stateRepository.GetAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
|
||||
if (state is not null)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
// Try by incident ID
|
||||
return await _stateRepository.GetByIncidentAsync(tenantId, stateIdOrIncidentId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<(bool Escalated, bool Exhausted)> ProcessEscalationAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationState state,
|
||||
NotifyEscalationPolicy policy,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nextLevel = state.CurrentLevel + 1;
|
||||
var iteration = state.RepeatIteration;
|
||||
|
||||
if (nextLevel >= policy.Levels.Length)
|
||||
{
|
||||
// Reached end of levels
|
||||
if (policy.RepeatEnabled && (policy.RepeatCount is null || iteration < policy.RepeatCount))
|
||||
{
|
||||
// Repeat from first level
|
||||
nextLevel = 0;
|
||||
iteration++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Exhausted all levels and repeats
|
||||
await _stateRepository.UpdateLevelAsync(
|
||||
tenantId,
|
||||
state.StateId,
|
||||
state.CurrentLevel,
|
||||
iteration,
|
||||
null, // No next escalation
|
||||
new NotifyEscalationAttempt(state.CurrentLevel, iteration, now, ImmutableArray<string>.Empty, true),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Escalation {StateId} exhausted all levels", state.StateId);
|
||||
return (false, true);
|
||||
}
|
||||
}
|
||||
|
||||
var level = policy.Levels[nextLevel];
|
||||
var nextEscalationAt = now.Add(level.EscalateAfter);
|
||||
|
||||
// Notify targets at this level
|
||||
var notifiedTargets = await NotifyLevelAsync(tenantId, state, policy, level, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var attempt = new NotifyEscalationAttempt(
|
||||
nextLevel,
|
||||
iteration,
|
||||
now,
|
||||
notifiedTargets.ToImmutableArray(),
|
||||
notifiedTargets.Count > 0);
|
||||
|
||||
await _stateRepository.UpdateLevelAsync(
|
||||
tenantId,
|
||||
state.StateId,
|
||||
nextLevel,
|
||||
iteration,
|
||||
nextEscalationAt,
|
||||
attempt,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Escalation {StateId} advanced to level {Level} iteration {Iteration}, notified {TargetCount} targets",
|
||||
state.StateId, nextLevel, iteration, notifiedTargets.Count);
|
||||
|
||||
return (true, false);
|
||||
}
|
||||
|
||||
private async Task<List<string>> NotifyLevelAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationState state,
|
||||
NotifyEscalationPolicy policy,
|
||||
NotifyEscalationLevel level,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var notifiedTargets = new List<string>();
|
||||
|
||||
foreach (var target in level.Targets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var notified = await NotifyTargetAsync(tenantId, state, target, cancellationToken).ConfigureAwait(false);
|
||||
if (notified)
|
||||
{
|
||||
notifiedTargets.Add($"{target.Type}:{target.TargetId}");
|
||||
}
|
||||
|
||||
// If NotifyAll is false, stop after first successful notification
|
||||
if (!level.NotifyAll && notified)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to notify target {TargetType}:{TargetId}", target.Type, target.TargetId);
|
||||
}
|
||||
}
|
||||
|
||||
return notifiedTargets;
|
||||
}
|
||||
|
||||
private async Task<bool> NotifyTargetAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationState state,
|
||||
NotifyEscalationTarget target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
switch (target.Type)
|
||||
{
|
||||
case NotifyEscalationTargetType.OnCallSchedule:
|
||||
var resolution = await _onCallResolver.ResolveAsync(tenantId, target.TargetId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (resolution.OnCallUsers.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogWarning("No on-call user found for schedule {ScheduleId}", target.TargetId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var notifiedAny = false;
|
||||
foreach (var user in resolution.OnCallUsers)
|
||||
{
|
||||
if (await NotifyUserAsync(tenantId, state, user, target.ChannelOverride, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
notifiedAny = true;
|
||||
}
|
||||
}
|
||||
return notifiedAny;
|
||||
|
||||
case NotifyEscalationTargetType.User:
|
||||
// For user targets, we'd need a user repository to get contact info
|
||||
// For now, log and return false
|
||||
_logger.LogDebug("User target notification not yet implemented: {UserId}", target.TargetId);
|
||||
return false;
|
||||
|
||||
case NotifyEscalationTargetType.Channel:
|
||||
// Send directly to a channel
|
||||
return await SendToChannelAsync(tenantId, state, target.TargetId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
case NotifyEscalationTargetType.ExternalService:
|
||||
// Would call PagerDuty/OpsGenie adapters
|
||||
_logger.LogDebug("External service target notification not yet implemented: {ServiceId}", target.TargetId);
|
||||
return false;
|
||||
|
||||
case NotifyEscalationTargetType.InAppInbox:
|
||||
// Would send to in-app inbox
|
||||
_logger.LogDebug("In-app inbox notification not yet implemented");
|
||||
return false;
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Unknown escalation target type: {TargetType}", target.Type);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> NotifyUserAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationState state,
|
||||
NotifyOnCallParticipant user,
|
||||
string? channelOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Prefer channel override if specified
|
||||
if (!string.IsNullOrWhiteSpace(channelOverride))
|
||||
{
|
||||
return await SendToChannelAsync(tenantId, state, channelOverride, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Try contact methods in order
|
||||
foreach (var method in user.ContactMethods.OrderBy(m => m.Priority))
|
||||
{
|
||||
if (!method.Enabled) continue;
|
||||
|
||||
// Map contact method to channel type
|
||||
var channelType = method.Type switch
|
||||
{
|
||||
NotifyContactMethodType.Email => NotifyChannelType.Email,
|
||||
NotifyContactMethodType.Slack => NotifyChannelType.Slack,
|
||||
NotifyContactMethodType.Teams => NotifyChannelType.Teams,
|
||||
NotifyContactMethodType.Webhook => NotifyChannelType.Webhook,
|
||||
_ => NotifyChannelType.Custom
|
||||
};
|
||||
|
||||
var adapter = _channelAdapters.FirstOrDefault(a => a.ChannelType == channelType);
|
||||
if (adapter is not null)
|
||||
{
|
||||
// Create a minimal rendered notification for the escalation
|
||||
var format = channelType switch
|
||||
{
|
||||
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
|
||||
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
|
||||
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
|
||||
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
|
||||
NotifyChannelType.PagerDuty => NotifyDeliveryFormat.PagerDuty,
|
||||
NotifyChannelType.OpsGenie => NotifyDeliveryFormat.OpsGenie,
|
||||
NotifyChannelType.Cli => NotifyDeliveryFormat.Cli,
|
||||
NotifyChannelType.InAppInbox => NotifyDeliveryFormat.InAppInbox,
|
||||
_ => NotifyDeliveryFormat.Json
|
||||
};
|
||||
|
||||
var rendered = NotifyDeliveryRendered.Create(
|
||||
channelType,
|
||||
format,
|
||||
method.Address,
|
||||
$"Escalation: Incident {state.IncidentId}",
|
||||
$"Incident {state.IncidentId} requires attention. Escalation level: {state.CurrentLevel + 1}");
|
||||
|
||||
// Get default channel config
|
||||
var channels = await _channelRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var channel = channels.FirstOrDefault(c => c.Type == channelType);
|
||||
|
||||
if (channel is not null)
|
||||
{
|
||||
var result = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogDebug("Notified user {UserId} via {ContactMethod}", user.UserId, method.Type);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to email if available
|
||||
if (!string.IsNullOrWhiteSpace(user.Email))
|
||||
{
|
||||
_logger.LogDebug("Would send email to {Email} for user {UserId}", user.Email, user.UserId);
|
||||
return true; // Assume success for now
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> SendToChannelAsync(
|
||||
string tenantId,
|
||||
NotifyEscalationState state,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
|
||||
if (channel is null)
|
||||
{
|
||||
_logger.LogWarning("Channel {ChannelId} not found for escalation", channelId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var adapter = _channelAdapters.FirstOrDefault(a => a.ChannelType == channel.Type);
|
||||
if (adapter is null)
|
||||
{
|
||||
_logger.LogWarning("No adapter found for channel type {ChannelType}", channel.Type);
|
||||
return false;
|
||||
}
|
||||
|
||||
var channelFormat = channel.Type switch
|
||||
{
|
||||
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
|
||||
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
|
||||
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
|
||||
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
|
||||
NotifyChannelType.PagerDuty => NotifyDeliveryFormat.PagerDuty,
|
||||
NotifyChannelType.OpsGenie => NotifyDeliveryFormat.OpsGenie,
|
||||
NotifyChannelType.Cli => NotifyDeliveryFormat.Cli,
|
||||
NotifyChannelType.InAppInbox => NotifyDeliveryFormat.InAppInbox,
|
||||
_ => NotifyDeliveryFormat.Json
|
||||
};
|
||||
|
||||
var rendered = NotifyDeliveryRendered.Create(
|
||||
channel.Type,
|
||||
channelFormat,
|
||||
channel.Config.Target ?? channel.Config.Endpoint ?? string.Empty,
|
||||
$"Escalation: Incident {state.IncidentId}",
|
||||
$"Incident {state.IncidentId} requires attention. Escalation level: {state.CurrentLevel + 1}. Policy: {state.PolicyId}");
|
||||
|
||||
var result = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
|
||||
return result.Success;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of on-call schedule resolution.
|
||||
/// </summary>
|
||||
public sealed class DefaultOnCallResolver : IOnCallResolver
|
||||
{
|
||||
private readonly INotifyOnCallScheduleRepository? _scheduleRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultOnCallResolver> _logger;
|
||||
|
||||
public DefaultOnCallResolver(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultOnCallResolver> logger,
|
||||
INotifyOnCallScheduleRepository? scheduleRepository = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_scheduleRepository = scheduleRepository;
|
||||
}
|
||||
|
||||
public async Task<NotifyOnCallResolution> ResolveAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
DateTimeOffset? evaluationTime = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scheduleId);
|
||||
|
||||
if (_scheduleRepository is null)
|
||||
{
|
||||
_logger.LogWarning("On-call schedule repository not available");
|
||||
return new NotifyOnCallResolution(scheduleId, evaluationTime ?? _timeProvider.GetUtcNow(), ImmutableArray<NotifyOnCallParticipant>.Empty);
|
||||
}
|
||||
|
||||
var schedule = await _scheduleRepository.GetAsync(tenantId, scheduleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (schedule is null)
|
||||
{
|
||||
_logger.LogWarning("On-call schedule {ScheduleId} not found for tenant {TenantId}", scheduleId, tenantId);
|
||||
return new NotifyOnCallResolution(scheduleId, evaluationTime ?? _timeProvider.GetUtcNow(), ImmutableArray<NotifyOnCallParticipant>.Empty);
|
||||
}
|
||||
|
||||
return ResolveAt(schedule, evaluationTime ?? _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
public NotifyOnCallResolution ResolveAt(
|
||||
NotifyOnCallSchedule schedule,
|
||||
DateTimeOffset evaluationTime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(schedule);
|
||||
|
||||
// Check for active override first
|
||||
var activeOverride = schedule.Overrides
|
||||
.FirstOrDefault(o => o.IsActiveAt(evaluationTime));
|
||||
|
||||
if (activeOverride is not null)
|
||||
{
|
||||
// Find the participant matching the override user ID
|
||||
var overrideUser = schedule.Layers
|
||||
.SelectMany(l => l.Participants)
|
||||
.FirstOrDefault(p => p.UserId == activeOverride.UserId);
|
||||
|
||||
if (overrideUser is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"On-call resolved from override {OverrideId} for schedule {ScheduleId}: user={UserId}",
|
||||
activeOverride.OverrideId, schedule.ScheduleId, activeOverride.UserId);
|
||||
|
||||
return new NotifyOnCallResolution(
|
||||
schedule.ScheduleId,
|
||||
evaluationTime,
|
||||
ImmutableArray.Create(overrideUser),
|
||||
sourceOverride: activeOverride.OverrideId);
|
||||
}
|
||||
|
||||
// Override user not in participants - create a minimal participant
|
||||
var minimalParticipant = NotifyOnCallParticipant.Create(activeOverride.UserId);
|
||||
return new NotifyOnCallResolution(
|
||||
schedule.ScheduleId,
|
||||
evaluationTime,
|
||||
ImmutableArray.Create(minimalParticipant),
|
||||
sourceOverride: activeOverride.OverrideId);
|
||||
}
|
||||
|
||||
// No override - find highest priority active layer
|
||||
var activeLayer = FindActiveLayer(schedule, evaluationTime);
|
||||
|
||||
if (activeLayer is null || activeLayer.Participants.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogDebug("No active on-call layer found for schedule {ScheduleId} at {EvaluationTime}",
|
||||
schedule.ScheduleId, evaluationTime);
|
||||
return new NotifyOnCallResolution(schedule.ScheduleId, evaluationTime, ImmutableArray<NotifyOnCallParticipant>.Empty);
|
||||
}
|
||||
|
||||
// Calculate who is on-call based on rotation
|
||||
var onCallUser = CalculateRotationUser(activeLayer, evaluationTime, schedule.TimeZone);
|
||||
|
||||
if (onCallUser is null)
|
||||
{
|
||||
_logger.LogDebug("No on-call user found in rotation for layer {LayerId}", activeLayer.LayerId);
|
||||
return new NotifyOnCallResolution(schedule.ScheduleId, evaluationTime, ImmutableArray<NotifyOnCallParticipant>.Empty);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"On-call resolved from layer {LayerId} for schedule {ScheduleId}: user={UserId}",
|
||||
activeLayer.LayerId, schedule.ScheduleId, onCallUser.UserId);
|
||||
|
||||
return new NotifyOnCallResolution(
|
||||
schedule.ScheduleId,
|
||||
evaluationTime,
|
||||
ImmutableArray.Create(onCallUser),
|
||||
sourceLayer: activeLayer.LayerId);
|
||||
}
|
||||
|
||||
private NotifyOnCallLayer? FindActiveLayer(NotifyOnCallSchedule schedule, DateTimeOffset evaluationTime)
|
||||
{
|
||||
// Order layers by priority (higher priority first)
|
||||
var orderedLayers = schedule.Layers.OrderByDescending(l => l.Priority);
|
||||
|
||||
foreach (var layer in orderedLayers)
|
||||
{
|
||||
if (IsLayerActiveAt(layer, evaluationTime, schedule.TimeZone))
|
||||
{
|
||||
return layer;
|
||||
}
|
||||
}
|
||||
|
||||
// If no layer matches restrictions, return highest priority layer
|
||||
return schedule.Layers.OrderByDescending(l => l.Priority).FirstOrDefault();
|
||||
}
|
||||
|
||||
private bool IsLayerActiveAt(NotifyOnCallLayer layer, DateTimeOffset evaluationTime, string timeZone)
|
||||
{
|
||||
if (layer.Restrictions is null || layer.Restrictions.TimeRanges.IsDefaultOrEmpty)
|
||||
{
|
||||
return true; // No restrictions = always active
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
|
||||
var localTime = TimeZoneInfo.ConvertTime(evaluationTime, tz);
|
||||
|
||||
foreach (var range in layer.Restrictions.TimeRanges)
|
||||
{
|
||||
var isTimeInRange = IsTimeInRange(localTime.TimeOfDay, range.StartTime, range.EndTime);
|
||||
|
||||
if (layer.Restrictions.Type == NotifyRestrictionType.DailyRestriction)
|
||||
{
|
||||
if (isTimeInRange) return true;
|
||||
}
|
||||
else if (layer.Restrictions.Type == NotifyRestrictionType.WeeklyRestriction)
|
||||
{
|
||||
if (range.DayOfWeek == localTime.DayOfWeek && isTimeInRange)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to evaluate layer restrictions for layer {LayerId}", layer.LayerId);
|
||||
return true; // On error, assume layer is active
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTimeInRange(TimeSpan current, TimeOnly start, TimeOnly end)
|
||||
{
|
||||
var currentTimeOnly = TimeOnly.FromTimeSpan(current);
|
||||
|
||||
if (start <= end)
|
||||
{
|
||||
return currentTimeOnly >= start && currentTimeOnly < end;
|
||||
}
|
||||
|
||||
// Handles overnight ranges (e.g., 22:00 - 06:00)
|
||||
return currentTimeOnly >= start || currentTimeOnly < end;
|
||||
}
|
||||
|
||||
private NotifyOnCallParticipant? CalculateRotationUser(
|
||||
NotifyOnCallLayer layer,
|
||||
DateTimeOffset evaluationTime,
|
||||
string timeZone)
|
||||
{
|
||||
if (layer.Participants.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var participantCount = layer.Participants.Length;
|
||||
if (participantCount == 1)
|
||||
{
|
||||
return layer.Participants[0];
|
||||
}
|
||||
|
||||
// Calculate rotation index based on time since rotation start
|
||||
var rotationStart = layer.RotationStartsAt;
|
||||
var elapsed = evaluationTime - rotationStart;
|
||||
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
// Evaluation time is before rotation start - return first participant
|
||||
return layer.Participants[0];
|
||||
}
|
||||
|
||||
var rotationCount = (long)(elapsed / layer.RotationInterval);
|
||||
var currentIndex = (int)(rotationCount % participantCount);
|
||||
|
||||
return layer.Participants[currentIndex];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Processes escalation state and triggers notifications at appropriate levels.
|
||||
/// </summary>
|
||||
public interface IEscalationEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts escalation for an incident.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationState> StartEscalationAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Processes pending escalations and advances to next level if needed.
|
||||
/// </summary>
|
||||
Task<EscalationProcessResult> ProcessPendingEscalationsAsync(
|
||||
string tenantId,
|
||||
int batchSize = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an escalation.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationState?> AcknowledgeAsync(
|
||||
string tenantId,
|
||||
string stateIdOrIncidentId,
|
||||
string acknowledgedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an escalation.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationState?> ResolveAsync(
|
||||
string tenantId,
|
||||
string stateIdOrIncidentId,
|
||||
string resolvedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current escalation state for an incident.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationState?> GetStateForIncidentAsync(
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of processing escalations.
|
||||
/// </summary>
|
||||
public sealed record EscalationProcessResult
|
||||
{
|
||||
public required int Processed { get; init; }
|
||||
public required int Escalated { get; init; }
|
||||
public required int Exhausted { get; init; }
|
||||
public required int Errors { get; init; }
|
||||
public IReadOnlyList<string>? ErrorMessages { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves who is currently on-call for a given schedule.
|
||||
/// </summary>
|
||||
public interface IOnCallResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the current on-call user(s) for a schedule.
|
||||
/// </summary>
|
||||
Task<NotifyOnCallResolution> ResolveAsync(
|
||||
string tenantId,
|
||||
string scheduleId,
|
||||
DateTimeOffset? evaluationTime = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current on-call user(s) for a schedule at a specific time.
|
||||
/// </summary>
|
||||
NotifyOnCallResolution ResolveAt(
|
||||
NotifyOnCallSchedule schedule,
|
||||
DateTimeOffset evaluationTime);
|
||||
}
|
||||
@@ -16,4 +16,14 @@ public sealed class NotifierWorkerOptions
|
||||
/// Default TTL for idempotency reservations when actions do not specify a throttle.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultIdempotencyTtl { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Poll interval for the dispatch worker when no pending deliveries are found.
|
||||
/// </summary>
|
||||
public TimeSpan DispatchPollInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of pending deliveries to process in a single dispatch batch.
|
||||
/// </summary>
|
||||
public int DispatchBatchSize { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
|
||||
public sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
|
||||
{
|
||||
private static readonly IDictionary<string, int> SeverityRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Renders notification templates with event payload data.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a template body using the provided data context.
|
||||
/// </summary>
|
||||
/// <param name="template">The template containing the body pattern.</param>
|
||||
/// <param name="payload">The event payload data to interpolate.</param>
|
||||
/// <returns>The rendered string.</returns>
|
||||
string Render(NotifyTemplate template, JsonNode? payload);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Background worker that picks up pending deliveries, renders templates, and dispatches through channels.
|
||||
/// </summary>
|
||||
public sealed class NotifierDispatchWorker : BackgroundService
|
||||
{
|
||||
private readonly INotifyDeliveryRepository _deliveryRepository;
|
||||
private readonly INotifyTemplateRepository _templateRepository;
|
||||
private readonly INotifyChannelRepository _channelRepository;
|
||||
private readonly INotifyTemplateRenderer _templateRenderer;
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> _channelAdapters;
|
||||
private readonly NotifierWorkerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifierDispatchWorker> _logger;
|
||||
private readonly string _workerId;
|
||||
|
||||
public NotifierDispatchWorker(
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyTemplateRepository templateRepository,
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyTemplateRenderer templateRenderer,
|
||||
IEnumerable<INotifyChannelAdapter> channelAdapters,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifierDispatchWorker> logger)
|
||||
{
|
||||
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
|
||||
_templateRepository = templateRepository ?? throw new ArgumentNullException(nameof(templateRepository));
|
||||
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
|
||||
_templateRenderer = templateRenderer ?? throw new ArgumentNullException(nameof(templateRenderer));
|
||||
_channelAdapters = BuildAdapterMap(channelAdapters);
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_workerId = $"notifier-dispatch-{Environment.MachineName}-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Notifier dispatch worker {WorkerId} started.", _workerId);
|
||||
|
||||
var pollInterval = _options.DispatchPollInterval > TimeSpan.Zero
|
||||
? _options.DispatchPollInterval
|
||||
: TimeSpan.FromSeconds(5);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessPendingDeliveriesAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (processed == 0)
|
||||
{
|
||||
await Task.Delay(pollInterval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception in dispatch worker loop.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Notifier dispatch worker {WorkerId} stopping.", _workerId);
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPendingDeliveriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Query for pending deliveries across all tenants (simplified - production would partition)
|
||||
var result = await _deliveryRepository.QueryAsync(
|
||||
tenantId: "tenant-sample", // In production, would iterate tenants
|
||||
since: null,
|
||||
status: "pending",
|
||||
limit: _options.DispatchBatchSize > 0 ? _options.DispatchBatchSize : 10,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.Items.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var processed = 0;
|
||||
foreach (var delivery in result.Items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessDeliveryAsync(delivery, cancellationToken).ConfigureAwait(false);
|
||||
processed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process delivery {DeliveryId}.", delivery.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task ProcessDeliveryAsync(NotifyDelivery delivery, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = delivery.TenantId;
|
||||
|
||||
// Look up channel from metadata
|
||||
if (!delivery.Metadata.TryGetValue("channel", out var channelId) || string.IsNullOrWhiteSpace(channelId))
|
||||
{
|
||||
await MarkDeliveryFailedAsync(delivery, "Channel reference missing in delivery metadata", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
|
||||
if (channel is null)
|
||||
{
|
||||
await MarkDeliveryFailedAsync(delivery, $"Channel {channelId} not found", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up template from metadata
|
||||
delivery.Metadata.TryGetValue("template", out var templateKey);
|
||||
delivery.Metadata.TryGetValue("locale", out var locale);
|
||||
locale ??= "en-us";
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
if (!string.IsNullOrWhiteSpace(templateKey))
|
||||
{
|
||||
// GetAsync uses templateId, so we look up by the template reference from metadata
|
||||
template = await _templateRepository.GetAsync(tenantId, templateKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Build rendered content
|
||||
NotifyDeliveryRendered rendered;
|
||||
if (template is not null)
|
||||
{
|
||||
// Create a payload from the delivery kind and metadata
|
||||
var payload = BuildPayloadFromDelivery(delivery);
|
||||
var renderedBody = _templateRenderer.Render(template, payload);
|
||||
|
||||
var subject = template.Metadata.TryGetValue("subject", out var subj)
|
||||
? _templateRenderer.Render(
|
||||
NotifyTemplate.Create(
|
||||
templateId: "subject-inline",
|
||||
tenantId: tenantId,
|
||||
channelType: template.ChannelType,
|
||||
key: "subject",
|
||||
locale: locale,
|
||||
body: subj),
|
||||
payload)
|
||||
: $"Notification: {delivery.Kind}";
|
||||
|
||||
rendered = NotifyDeliveryRendered.Create(
|
||||
channelType: channel.Type,
|
||||
format: template.Format,
|
||||
target: channel.Config?.Target ?? string.Empty,
|
||||
title: subject,
|
||||
body: renderedBody,
|
||||
locale: locale);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback rendering without template
|
||||
rendered = NotifyDeliveryRendered.Create(
|
||||
channelType: channel.Type,
|
||||
format: NotifyDeliveryFormat.Json,
|
||||
target: channel.Config?.Target ?? string.Empty,
|
||||
title: $"Notification: {delivery.Kind}",
|
||||
body: $"Event {delivery.EventId} triggered rule {delivery.RuleId}",
|
||||
locale: locale);
|
||||
}
|
||||
|
||||
// Dispatch through channel adapter
|
||||
if (!_channelAdapters.TryGetValue(channel.Type, out var adapter))
|
||||
{
|
||||
await MarkDeliveryFailedAsync(delivery, $"No adapter for channel type {channel.Type}", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var dispatchResult = await adapter.SendAsync(channel, rendered, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Update delivery with result
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: _timeProvider.GetUtcNow(),
|
||||
status: dispatchResult.Success ? NotifyDeliveryAttemptStatus.Succeeded : NotifyDeliveryAttemptStatus.Failed,
|
||||
statusCode: dispatchResult.StatusCode,
|
||||
reason: dispatchResult.Reason);
|
||||
|
||||
var newStatus = dispatchResult.Success
|
||||
? NotifyDeliveryStatus.Sent
|
||||
: (dispatchResult.ShouldRetry ? NotifyDeliveryStatus.Pending : NotifyDeliveryStatus.Failed);
|
||||
|
||||
var updatedDelivery = NotifyDelivery.Create(
|
||||
deliveryId: delivery.DeliveryId,
|
||||
tenantId: delivery.TenantId,
|
||||
ruleId: delivery.RuleId,
|
||||
actionId: delivery.ActionId,
|
||||
eventId: delivery.EventId,
|
||||
kind: delivery.Kind,
|
||||
status: newStatus,
|
||||
statusReason: dispatchResult.Reason,
|
||||
rendered: rendered,
|
||||
attempts: delivery.Attempts.Add(attempt),
|
||||
metadata: delivery.Metadata,
|
||||
createdAt: delivery.CreatedAt,
|
||||
sentAt: dispatchResult.Success ? _timeProvider.GetUtcNow() : delivery.SentAt,
|
||||
completedAt: newStatus == NotifyDeliveryStatus.Sent || newStatus == NotifyDeliveryStatus.Failed
|
||||
? _timeProvider.GetUtcNow()
|
||||
: null);
|
||||
|
||||
await _deliveryRepository.UpdateAsync(updatedDelivery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Delivery {DeliveryId} dispatched via {ChannelType}: {Status}",
|
||||
delivery.DeliveryId,
|
||||
channel.Type,
|
||||
newStatus);
|
||||
}
|
||||
|
||||
private async Task MarkDeliveryFailedAsync(
|
||||
NotifyDelivery delivery,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failedDelivery = NotifyDelivery.Create(
|
||||
deliveryId: delivery.DeliveryId,
|
||||
tenantId: delivery.TenantId,
|
||||
ruleId: delivery.RuleId,
|
||||
actionId: delivery.ActionId,
|
||||
eventId: delivery.EventId,
|
||||
kind: delivery.Kind,
|
||||
status: NotifyDeliveryStatus.Failed,
|
||||
statusReason: reason,
|
||||
attempts: delivery.Attempts,
|
||||
metadata: delivery.Metadata,
|
||||
createdAt: delivery.CreatedAt,
|
||||
completedAt: _timeProvider.GetUtcNow());
|
||||
|
||||
await _deliveryRepository.UpdateAsync(failedDelivery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning("Delivery {DeliveryId} marked failed: {Reason}", delivery.DeliveryId, reason);
|
||||
}
|
||||
|
||||
private static JsonObject BuildPayloadFromDelivery(NotifyDelivery delivery)
|
||||
{
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["eventId"] = delivery.EventId.ToString(),
|
||||
["kind"] = delivery.Kind,
|
||||
["ruleId"] = delivery.RuleId,
|
||||
["actionId"] = delivery.ActionId
|
||||
};
|
||||
|
||||
foreach (var (key, value) in delivery.Metadata)
|
||||
{
|
||||
payload[key] = value;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelAdapter> BuildAdapterMap(
|
||||
IEnumerable<INotifyChannelAdapter> adapters)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<NotifyChannelType, INotifyChannelAdapter>();
|
||||
foreach (var adapter in adapters)
|
||||
{
|
||||
builder[adapter.ChannelType] = adapter;
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
/// <summary>
|
||||
/// Simple Handlebars-like template renderer supporting {{property}} and {{#each}} blocks.
|
||||
/// </summary>
|
||||
public sealed partial class SimpleTemplateRenderer : INotifyTemplateRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
|
||||
private static readonly Regex EachBlockPattern = EachBlockRegex();
|
||||
|
||||
public string Render(NotifyTemplate template, JsonNode? payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
|
||||
var body = template.Body;
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Process {{#each}} blocks first
|
||||
body = ProcessEachBlocks(body, payload);
|
||||
|
||||
// Then substitute simple placeholders
|
||||
body = SubstitutePlaceholders(body, payload);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ProcessEachBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
return EachBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var collectionPath = match.Groups[1].Value.Trim();
|
||||
var innerTemplate = match.Groups[2].Value;
|
||||
|
||||
var collection = ResolvePath(payload, collectionPath);
|
||||
if (collection is not JsonObject obj)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var results = new List<string>();
|
||||
foreach (var (key, value) in obj)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@key}}", key)
|
||||
.Replace("{{this}}", value?.ToString() ?? string.Empty);
|
||||
results.Add(itemResult);
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
});
|
||||
}
|
||||
|
||||
private static string SubstitutePlaceholders(string body, JsonNode? payload)
|
||||
{
|
||||
return PlaceholderPattern.Replace(body, match =>
|
||||
{
|
||||
var path = match.Groups[1].Value.Trim();
|
||||
var resolved = ResolvePath(payload, path);
|
||||
return resolved?.ToString() ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonNode? ResolvePath(JsonNode? root, string path)
|
||||
{
|
||||
if (root is null || string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path.Split('.');
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
|
||||
{
|
||||
current = next;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex EachBlockRegex();
|
||||
}
|
||||
@@ -2,10 +2,11 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notifier.Worker.Channels;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
@@ -25,10 +26,10 @@ builder.Logging.AddSimpleConsole(options =>
|
||||
builder.Services.Configure<NotifierWorkerOptions>(builder.Configuration.GetSection("notifier:worker"));
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration);
|
||||
|
||||
builder.Services.AddNotifyEventQueue(builder.Configuration, "notifier:queue");
|
||||
builder.Services.AddHealthChecks().AddNotifyQueueHealthCheck();
|
||||
@@ -38,4 +39,19 @@ builder.Services.AddSingleton<NotifierEventProcessor>();
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
builder.Services.AddHostedService<NotifierEventWorker>();
|
||||
|
||||
// Template rendering
|
||||
builder.Services.AddSingleton<INotifyTemplateRenderer, SimpleTemplateRenderer>();
|
||||
|
||||
// Channel adapters with HttpClient for webhook/Slack
|
||||
builder.Services.AddHttpClient<WebhookChannelAdapter>();
|
||||
builder.Services.AddHttpClient<SlackChannelAdapter>();
|
||||
builder.Services.AddSingleton<INotifyChannelAdapter, WebhookChannelAdapter>(sp =>
|
||||
sp.GetRequiredService<WebhookChannelAdapter>());
|
||||
builder.Services.AddSingleton<INotifyChannelAdapter, SlackChannelAdapter>(sp =>
|
||||
sp.GetRequiredService<SlackChannelAdapter>());
|
||||
builder.Services.AddSingleton<INotifyChannelAdapter, EmailChannelAdapter>();
|
||||
|
||||
// Dispatch worker for rendering and sending notifications
|
||||
builder.Services.AddHostedService<NotifierDispatchWorker>();
|
||||
|
||||
await builder.Build().RunAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of the notification simulation engine.
|
||||
/// Dry-runs rules against events to preview what actions would be triggered.
|
||||
/// </summary>
|
||||
public sealed class DefaultNotifySimulationEngine : INotifySimulationEngine
|
||||
{
|
||||
private readonly INotifyRuleRepository _ruleRepository;
|
||||
private readonly INotifyChannelRepository _channelRepository;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly INotifyRuleEvaluator _ruleEvaluator;
|
||||
private readonly INotifyThrottler? _throttler;
|
||||
private readonly IQuietHoursEvaluator? _quietHoursEvaluator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultNotifySimulationEngine> _logger;
|
||||
|
||||
private static readonly TimeSpan DefaultThrottleWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
public DefaultNotifySimulationEngine(
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyChannelRepository channelRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
INotifyRuleEvaluator ruleEvaluator,
|
||||
INotifyThrottler? throttler,
|
||||
IQuietHoursEvaluator? quietHoursEvaluator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultNotifySimulationEngine> logger)
|
||||
{
|
||||
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
|
||||
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
|
||||
_throttler = throttler;
|
||||
_quietHoursEvaluator = quietHoursEvaluator;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<NotifySimulationResult> SimulateAsync(
|
||||
NotifySimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var simulationId = Guid.NewGuid().ToString("N");
|
||||
var evaluationTime = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting simulation {SimulationId} for tenant {TenantId}: period {PeriodStart} to {PeriodEnd}",
|
||||
simulationId, request.TenantId, request.PeriodStart, request.PeriodEnd);
|
||||
|
||||
// Load rules
|
||||
var allRules = await _ruleRepository.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var rules = FilterRules(allRules, request.RuleIds);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Simulation {SimulationId}: loaded {RuleCount} rules ({FilteredCount} after filtering)",
|
||||
simulationId, allRules.Count, rules.Count);
|
||||
|
||||
// Load historical events from audit log
|
||||
var auditEntries = await _auditRepository.QueryAsync(
|
||||
request.TenantId,
|
||||
request.PeriodStart,
|
||||
request.MaxEvents,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert audit entries to events for simulation
|
||||
var events = ConvertAuditEntriesToEvents(auditEntries, request.PeriodStart, request.PeriodEnd, request.EventKinds);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Simulation {SimulationId}: loaded {EventCount} events from audit log",
|
||||
simulationId, events.Count);
|
||||
|
||||
// Load channels for action evaluation
|
||||
var channels = await LoadChannelsAsync(request.TenantId, rules, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Run simulation
|
||||
var eventResults = new List<SimulatedEventResult>();
|
||||
var ruleSummaries = new Dictionary<string, RuleSummaryBuilder>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
ruleSummaries[rule.RuleId] = new RuleSummaryBuilder(rule);
|
||||
}
|
||||
|
||||
foreach (var @event in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var eventResult = await SimulateEventAsync(
|
||||
@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken).ConfigureAwait(false);
|
||||
eventResults.Add(eventResult);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var result = new NotifySimulationResult
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
TenantId = request.TenantId,
|
||||
SimulatedAt = _timeProvider.GetUtcNow(),
|
||||
EventsEvaluated = events.Count,
|
||||
RulesEvaluated = rules.Count,
|
||||
TotalMatches = eventResults.Sum(e => e.MatchedRules),
|
||||
TotalActions = eventResults.Sum(e => e.TriggeredActions),
|
||||
EventResults = eventResults.ToImmutableArray(),
|
||||
RuleSummaries = ruleSummaries.Values
|
||||
.Select(b => b.Build())
|
||||
.OrderByDescending(s => s.MatchCount)
|
||||
.ToImmutableArray(),
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed simulation {SimulationId}: {EventsEvaluated} events, {TotalMatches} matches, {TotalActions} actions in {Duration}ms",
|
||||
simulationId, result.EventsEvaluated, result.TotalMatches, result.TotalActions, result.Duration.TotalMilliseconds);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<SimulatedEventResult> SimulateSingleEventAsync(
|
||||
string tenantId,
|
||||
JsonObject eventPayload,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
DateTimeOffset? evaluationTimestamp = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(eventPayload);
|
||||
|
||||
var evaluationTime = evaluationTimestamp ?? _timeProvider.GetUtcNow();
|
||||
|
||||
// Parse event from payload
|
||||
var @event = ParseEventFromPayload(tenantId, eventPayload);
|
||||
|
||||
// Load rules
|
||||
var allRules = await _ruleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var rules = FilterRules(allRules, ruleIds?.ToImmutableArray() ?? []);
|
||||
|
||||
// Load channels
|
||||
var channels = await LoadChannelsAsync(tenantId, rules, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create dummy request for simulation
|
||||
var request = new NotifySimulationRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PeriodStart = evaluationTime.AddHours(-1),
|
||||
PeriodEnd = evaluationTime,
|
||||
EvaluationTimestamp = evaluationTime,
|
||||
EvaluateThrottling = true,
|
||||
EvaluateQuietHours = true,
|
||||
IncludeNonMatches = true
|
||||
};
|
||||
|
||||
var ruleSummaries = new Dictionary<string, RuleSummaryBuilder>(StringComparer.Ordinal);
|
||||
return await SimulateEventAsync(@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<SimulatedEventResult> SimulateEventAsync(
|
||||
NotifyEvent @event,
|
||||
IReadOnlyList<NotifyRule> rules,
|
||||
IReadOnlyDictionary<string, NotifyChannel> channels,
|
||||
NotifySimulationRequest request,
|
||||
DateTimeOffset evaluationTime,
|
||||
Dictionary<string, RuleSummaryBuilder> ruleSummaries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matches = new List<SimulatedRuleMatch>();
|
||||
var nonMatches = new List<SimulatedRuleNonMatch>();
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var outcome = _ruleEvaluator.Evaluate(rule, @event, evaluationTime);
|
||||
|
||||
if (outcome.IsMatch)
|
||||
{
|
||||
var actionResults = await EvaluateActionsAsync(
|
||||
@event, rule, outcome.Actions, channels, request, evaluationTime, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var explanations = BuildMatchExplanations(rule, @event);
|
||||
|
||||
matches.Add(new SimulatedRuleMatch
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
RuleName = rule.Name ?? rule.RuleId,
|
||||
Priority = 0, // NotifyRule doesn't have priority, default to 0
|
||||
MatchedAt = outcome.MatchedAt ?? evaluationTime,
|
||||
Actions = actionResults,
|
||||
MatchExplanations = explanations
|
||||
});
|
||||
|
||||
if (ruleSummaries.TryGetValue(rule.RuleId, out var summary))
|
||||
{
|
||||
summary.RecordMatch(actionResults.Length);
|
||||
}
|
||||
}
|
||||
else if (request.IncludeNonMatches)
|
||||
{
|
||||
var explanation = BuildNonMatchExplanation(outcome.Reason ?? "unknown", rule, @event);
|
||||
|
||||
nonMatches.Add(new SimulatedRuleNonMatch
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
RuleName = rule.Name ?? rule.RuleId,
|
||||
Reason = outcome.Reason ?? "unknown",
|
||||
Explanation = explanation
|
||||
});
|
||||
|
||||
if (ruleSummaries.TryGetValue(rule.RuleId, out var summary))
|
||||
{
|
||||
summary.RecordNonMatch(outcome.Reason ?? "unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SimulatedEventResult
|
||||
{
|
||||
EventId = @event.EventId,
|
||||
Kind = @event.Kind,
|
||||
EventTimestamp = @event.Ts,
|
||||
MatchedRules = matches.Count,
|
||||
TriggeredActions = matches.Sum(m => m.Actions.Count(a => a.WouldDeliver)),
|
||||
Matches = matches.OrderBy(m => m.Priority).ToImmutableArray(),
|
||||
NonMatches = nonMatches.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<SimulatedActionResult>> EvaluateActionsAsync(
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
ImmutableArray<NotifyRuleAction> actions,
|
||||
IReadOnlyDictionary<string, NotifyChannel> channels,
|
||||
NotifySimulationRequest request,
|
||||
DateTimeOffset evaluationTime,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SimulatedActionResult>();
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (!action.Enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var channelId = action.Channel?.Trim() ?? string.Empty;
|
||||
channels.TryGetValue(channelId, out var channel);
|
||||
|
||||
var wouldDeliver = true;
|
||||
var deliveryExplanation = "Would be delivered successfully";
|
||||
string? throttleReason = null;
|
||||
string? quietHoursReason = null;
|
||||
string? channelBlockReason = null;
|
||||
|
||||
// Check channel availability
|
||||
if (channel is null)
|
||||
{
|
||||
wouldDeliver = false;
|
||||
channelBlockReason = $"Channel '{channelId}' not found";
|
||||
deliveryExplanation = channelBlockReason;
|
||||
}
|
||||
else if (!channel.Enabled)
|
||||
{
|
||||
wouldDeliver = false;
|
||||
channelBlockReason = $"Channel '{channelId}' is disabled";
|
||||
deliveryExplanation = channelBlockReason;
|
||||
}
|
||||
|
||||
// Check throttling
|
||||
if (wouldDeliver && request.EvaluateThrottling && _throttler is not null)
|
||||
{
|
||||
var throttleKey = $"{rule.RuleId}:{action.ActionId}:{@event.Kind}";
|
||||
var throttleWindow = action.Throttle is { Ticks: > 0 } ? action.Throttle.Value : DefaultThrottleWindow;
|
||||
var isThrottled = await _throttler.IsThrottledAsync(
|
||||
@event.Tenant, throttleKey, throttleWindow, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (isThrottled)
|
||||
{
|
||||
wouldDeliver = false;
|
||||
throttleReason = $"Would be throttled (key: {throttleKey})";
|
||||
deliveryExplanation = throttleReason;
|
||||
}
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
if (wouldDeliver && request.EvaluateQuietHours && _quietHoursEvaluator is not null)
|
||||
{
|
||||
var quietHoursResult = await _quietHoursEvaluator.IsInQuietHoursAsync(
|
||||
@event.Tenant, channelId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (quietHoursResult.IsInQuietHours)
|
||||
{
|
||||
wouldDeliver = false;
|
||||
quietHoursReason = quietHoursResult.Reason ?? "In quiet hours period";
|
||||
deliveryExplanation = quietHoursReason;
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldDeliver)
|
||||
{
|
||||
deliveryExplanation = $"Would deliver to {channel?.Type.ToString() ?? "unknown"} channel '{channelId}'";
|
||||
if (!string.IsNullOrWhiteSpace(action.Template))
|
||||
{
|
||||
deliveryExplanation += $" using template '{action.Template}'";
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(new SimulatedActionResult
|
||||
{
|
||||
ActionId = action.ActionId,
|
||||
ChannelId = channelId,
|
||||
ChannelType = channel?.Type ?? NotifyChannelType.Custom,
|
||||
TemplateId = action.Template,
|
||||
WouldDeliver = wouldDeliver,
|
||||
DeliveryExplanation = deliveryExplanation,
|
||||
ThrottleReason = throttleReason,
|
||||
QuietHoursReason = quietHoursReason,
|
||||
ChannelBlockReason = channelBlockReason
|
||||
});
|
||||
}
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildMatchExplanations(NotifyRule rule, NotifyEvent @event)
|
||||
{
|
||||
var explanations = new List<string>();
|
||||
var match = rule.Match;
|
||||
|
||||
if (!match.EventKinds.IsDefaultOrEmpty)
|
||||
{
|
||||
explanations.Add($"Event kind '{@event.Kind}' matched filter [{string.Join(", ", match.EventKinds)}]");
|
||||
}
|
||||
else
|
||||
{
|
||||
explanations.Add("Event kind matched (no filter specified)");
|
||||
}
|
||||
|
||||
if (!match.Namespaces.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Namespace))
|
||||
{
|
||||
explanations.Add($"Namespace '{@event.Scope.Namespace}' matched filter");
|
||||
}
|
||||
|
||||
if (!match.Repositories.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Repo))
|
||||
{
|
||||
explanations.Add($"Repository '{@event.Scope.Repo}' matched filter");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(match.MinSeverity))
|
||||
{
|
||||
explanations.Add($"Severity met minimum threshold of '{match.MinSeverity}'");
|
||||
}
|
||||
|
||||
if (!match.Labels.IsDefaultOrEmpty)
|
||||
{
|
||||
explanations.Add($"Labels matched required set: [{string.Join(", ", match.Labels)}]");
|
||||
}
|
||||
|
||||
return explanations.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string BuildNonMatchExplanation(string reason, NotifyRule rule, NotifyEvent @event)
|
||||
{
|
||||
return reason switch
|
||||
{
|
||||
"rule_disabled" => $"Rule '{rule.Name ?? rule.RuleId}' is disabled",
|
||||
"event_kind_mismatch" => $"Event kind '{@event.Kind}' not in rule filter [{string.Join(", ", rule.Match.EventKinds)}]",
|
||||
"namespace_mismatch" => $"Namespace '{@event.Scope?.Namespace ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Namespaces)}]",
|
||||
"repository_mismatch" => $"Repository '{@event.Scope?.Repo ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Repositories)}]",
|
||||
"digest_mismatch" => $"Digest '{@event.Scope?.Digest ?? "(none)"}' not in rule filter",
|
||||
"component_mismatch" => "Event component PURLs did not match rule filter",
|
||||
"kev_required" => "Rule requires KEV label but event does not have it",
|
||||
"label_mismatch" => $"Event labels did not match required set [{string.Join(", ", rule.Match.Labels)}]",
|
||||
"severity_below_threshold" => $"Event severity below minimum '{rule.Match.MinSeverity}'",
|
||||
"verdict_mismatch" => $"Event verdict not in rule filter [{string.Join(", ", rule.Match.Verdicts)}]",
|
||||
"no_enabled_actions" => "Rule has no enabled actions",
|
||||
_ => $"Rule did not match: {reason}"
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NotifyRule> FilterRules(
|
||||
IReadOnlyList<NotifyRule> rules,
|
||||
ImmutableArray<string> ruleIds)
|
||||
{
|
||||
if (ruleIds.IsDefaultOrEmpty)
|
||||
{
|
||||
return rules.Where(r => r.Enabled).ToList();
|
||||
}
|
||||
|
||||
var ruleIdSet = ruleIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return rules.Where(r => ruleIdSet.Contains(r.RuleId)).ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, NotifyChannel>> LoadChannelsAsync(
|
||||
string tenantId,
|
||||
IReadOnlyList<NotifyRule> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var channelIds = rules
|
||||
.SelectMany(r => r.Actions)
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Channel))
|
||||
.Select(a => a.Channel!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var channels = new Dictionary<string, NotifyChannel>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var channelId in channelIds)
|
||||
{
|
||||
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
|
||||
if (channel is not null)
|
||||
{
|
||||
channels[channelId] = channel;
|
||||
}
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NotifyEvent> ConvertAuditEntriesToEvents(
|
||||
IReadOnlyList<NotifyAuditEntryDocument> auditEntries,
|
||||
DateTimeOffset periodStart,
|
||||
DateTimeOffset periodEnd,
|
||||
ImmutableArray<string> eventKinds)
|
||||
{
|
||||
var kindSet = eventKinds.IsDefaultOrEmpty
|
||||
? null
|
||||
: eventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var events = new List<NotifyEvent>();
|
||||
|
||||
foreach (var entry in auditEntries)
|
||||
{
|
||||
// Skip entries outside the period
|
||||
if (entry.Timestamp < periodStart || entry.Timestamp >= periodEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract event info from the audit entry's action or payload
|
||||
// Audit entries may not contain full event data, so we reconstruct what we can
|
||||
var eventKind = ExtractEventKindFromAuditEntry(entry);
|
||||
if (string.IsNullOrWhiteSpace(eventKind))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by event kind if specified
|
||||
if (kindSet is not null && !kindSet.Contains(eventKind))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var eventId = ExtractEventIdFromAuditEntry(entry);
|
||||
|
||||
var @event = NotifyEvent.Create(
|
||||
eventId: eventId,
|
||||
kind: eventKind,
|
||||
tenant: entry.TenantId,
|
||||
ts: entry.Timestamp,
|
||||
payload: TryParsePayloadFromBson(entry.Payload));
|
||||
|
||||
events.Add(@event);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static string? ExtractEventKindFromAuditEntry(NotifyAuditEntryDocument entry)
|
||||
{
|
||||
// The event kind might be encoded in the action field or payload
|
||||
// Action format is typically "event.kind.action" or we look in payload
|
||||
var action = entry.Action;
|
||||
|
||||
// Try to extract from action (e.g., "pack.approval.ingested" -> "pack.approval")
|
||||
if (!string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
var parts = action.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
return string.Join(".", parts.Take(parts.Length - 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from payload
|
||||
if (entry.Payload is { } payload)
|
||||
{
|
||||
if (payload.TryGetValue("Kind", out var kindValue) || payload.TryGetValue("kind", out kindValue))
|
||||
{
|
||||
return kindValue.AsString;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Guid ExtractEventIdFromAuditEntry(NotifyAuditEntryDocument entry)
|
||||
{
|
||||
// Try to extract event ID from payload
|
||||
if (entry.Payload is { } payload)
|
||||
{
|
||||
if (payload.TryGetValue("EventId", out var eventIdValue) || payload.TryGetValue("eventId", out eventIdValue))
|
||||
{
|
||||
if (Guid.TryParse(eventIdValue.ToString(), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try entity ID
|
||||
if (Guid.TryParse(entry.EntityId, out var entityId))
|
||||
{
|
||||
return entityId;
|
||||
}
|
||||
|
||||
return Guid.NewGuid();
|
||||
}
|
||||
|
||||
private static JsonNode? TryParsePayloadFromBson(MongoDB.Bson.BsonDocument? payload)
|
||||
{
|
||||
if (payload is null || payload.IsBsonNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use MongoDB.Bson.BsonExtensionMethods.ToJson extension method
|
||||
var json = MongoDB.Bson.BsonExtensionMethods.ToJson(payload);
|
||||
return JsonNode.Parse(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static NotifyEvent ParseEventFromPayload(string tenantId, JsonObject payload)
|
||||
{
|
||||
var eventId = payload.TryGetPropertyValue("eventId", out var idNode) && idNode is JsonValue idValue
|
||||
? (Guid.TryParse(idValue.ToString(), out var id) ? id : Guid.NewGuid())
|
||||
: Guid.NewGuid();
|
||||
|
||||
var kind = payload.TryGetPropertyValue("kind", out var kindNode) && kindNode is JsonValue kindValue
|
||||
? kindValue.ToString()
|
||||
: "simulation.test";
|
||||
|
||||
var ts = payload.TryGetPropertyValue("ts", out var tsNode) && tsNode is JsonValue tsValue
|
||||
&& DateTimeOffset.TryParse(tsValue.ToString(), out var timestamp)
|
||||
? timestamp
|
||||
: DateTimeOffset.UtcNow;
|
||||
|
||||
var eventPayload = payload.TryGetPropertyValue("payload", out var payloadNode)
|
||||
? payloadNode
|
||||
: payload;
|
||||
|
||||
NotifyEventScope? scope = null;
|
||||
if (payload.TryGetPropertyValue("scope", out var scopeNode) && scopeNode is JsonObject scopeObj)
|
||||
{
|
||||
scope = NotifyEventScope.Create(
|
||||
@namespace: GetStringProperty(scopeObj, "namespace"),
|
||||
repo: GetStringProperty(scopeObj, "repo"),
|
||||
digest: GetStringProperty(scopeObj, "digest"),
|
||||
component: GetStringProperty(scopeObj, "component"),
|
||||
image: GetStringProperty(scopeObj, "image"));
|
||||
}
|
||||
|
||||
var attributes = ImmutableDictionary<string, string>.Empty;
|
||||
if (payload.TryGetPropertyValue("attributes", out var attrNode) && attrNode is JsonObject attrObj)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var prop in attrObj)
|
||||
{
|
||||
if (prop.Value is JsonValue value)
|
||||
{
|
||||
builder[prop.Key] = value.ToString();
|
||||
}
|
||||
}
|
||||
attributes = builder.ToImmutable();
|
||||
}
|
||||
|
||||
return NotifyEvent.Create(
|
||||
eventId: eventId,
|
||||
kind: kind,
|
||||
tenant: tenantId,
|
||||
ts: ts,
|
||||
payload: eventPayload,
|
||||
scope: scope,
|
||||
attributes: attributes);
|
||||
}
|
||||
|
||||
private static string? GetStringProperty(JsonObject obj, string name)
|
||||
{
|
||||
return obj.TryGetPropertyValue(name, out var node) && node is JsonValue value
|
||||
? value.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private sealed class RuleSummaryBuilder
|
||||
{
|
||||
private readonly NotifyRule _rule;
|
||||
private int _matchCount;
|
||||
private int _actionCount;
|
||||
private readonly Dictionary<string, int> _nonMatchReasons = new(StringComparer.Ordinal);
|
||||
|
||||
public RuleSummaryBuilder(NotifyRule rule)
|
||||
{
|
||||
_rule = rule;
|
||||
}
|
||||
|
||||
public void RecordMatch(int actions)
|
||||
{
|
||||
_matchCount++;
|
||||
_actionCount += actions;
|
||||
}
|
||||
|
||||
public void RecordNonMatch(string reason)
|
||||
{
|
||||
_nonMatchReasons.TryGetValue(reason, out var count);
|
||||
_nonMatchReasons[reason] = count + 1;
|
||||
}
|
||||
|
||||
public SimulatedRuleSummary Build()
|
||||
{
|
||||
return new SimulatedRuleSummary
|
||||
{
|
||||
RuleId = _rule.RuleId,
|
||||
RuleName = _rule.Name ?? _rule.RuleId,
|
||||
MatchCount = _matchCount,
|
||||
ActionCount = _actionCount,
|
||||
NonMatchReasons = _nonMatchReasons.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Engine for simulating notification rules against historical events.
|
||||
/// Allows dry-run testing of rules before enabling them in production.
|
||||
/// </summary>
|
||||
public interface INotifySimulationEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a simulation against historical events.
|
||||
/// </summary>
|
||||
/// <param name="request">The simulation request parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The simulation result with matched actions and explanations.</returns>
|
||||
Task<NotifySimulationResult> SimulateAsync(
|
||||
NotifySimulationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a single event against the current rules.
|
||||
/// Useful for real-time what-if analysis.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="eventPayload">The event payload to simulate.</param>
|
||||
/// <param name="ruleIds">Optional specific rule IDs to test.</param>
|
||||
/// <param name="evaluationTimestamp">Timestamp for throttle/quiet hours evaluation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The simulated event result.</returns>
|
||||
Task<SimulatedEventResult> SimulateSingleEventAsync(
|
||||
string tenantId,
|
||||
System.Text.Json.Nodes.JsonObject eventPayload,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
DateTimeOffset? evaluationTimestamp = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a notification rule simulation.
|
||||
/// </summary>
|
||||
public sealed record NotifySimulationResult
|
||||
{
|
||||
public required string SimulationId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset SimulatedAt { get; init; }
|
||||
public required int EventsEvaluated { get; init; }
|
||||
public required int RulesEvaluated { get; init; }
|
||||
public required int TotalMatches { get; init; }
|
||||
public required int TotalActions { get; init; }
|
||||
public required ImmutableArray<SimulatedEventResult> EventResults { get; init; }
|
||||
public required ImmutableArray<SimulatedRuleSummary> RuleSummaries { get; init; }
|
||||
public TimeSpan Duration { get; init; }
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of simulating rules against a single event.
|
||||
/// </summary>
|
||||
public sealed record SimulatedEventResult
|
||||
{
|
||||
public required Guid EventId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required DateTimeOffset EventTimestamp { get; init; }
|
||||
public required int MatchedRules { get; init; }
|
||||
public required int TriggeredActions { get; init; }
|
||||
public required ImmutableArray<SimulatedRuleMatch> Matches { get; init; }
|
||||
public required ImmutableArray<SimulatedRuleNonMatch> NonMatches { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a rule that matched during simulation.
|
||||
/// </summary>
|
||||
public sealed record SimulatedRuleMatch
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string RuleName { get; init; }
|
||||
public required int Priority { get; init; }
|
||||
public required DateTimeOffset MatchedAt { get; init; }
|
||||
public required ImmutableArray<SimulatedActionResult> Actions { get; init; }
|
||||
public required ImmutableArray<string> MatchExplanations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a rule that did not match during simulation.
|
||||
/// </summary>
|
||||
public sealed record SimulatedRuleNonMatch
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string RuleName { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required string Explanation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a simulated action (what would have happened).
|
||||
/// </summary>
|
||||
public sealed record SimulatedActionResult
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required NotifyChannelType ChannelType { get; init; }
|
||||
public required string? TemplateId { get; init; }
|
||||
public required bool WouldDeliver { get; init; }
|
||||
public required string DeliveryExplanation { get; init; }
|
||||
public string? ThrottleReason { get; init; }
|
||||
public string? QuietHoursReason { get; init; }
|
||||
public string? ChannelBlockReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of how a rule performed across all simulated events.
|
||||
/// </summary>
|
||||
public sealed record SimulatedRuleSummary
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string RuleName { get; init; }
|
||||
public required int MatchCount { get; init; }
|
||||
public required int ActionCount { get; init; }
|
||||
public required ImmutableDictionary<string, int> NonMatchReasons { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request parameters for running a simulation.
|
||||
/// </summary>
|
||||
public sealed record NotifySimulationRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant ID to simulate for.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the time range to query historical events.
|
||||
/// </summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the time range to query historical events.
|
||||
/// </summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: specific rule IDs to simulate. If empty, all enabled rules are used.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Optional: filter to specific event kinds.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EventKinds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of events to evaluate.
|
||||
/// </summary>
|
||||
public int MaxEvents { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include non-match details in results.
|
||||
/// </summary>
|
||||
public bool IncludeNonMatches { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to evaluate throttling rules.
|
||||
/// </summary>
|
||||
public bool EvaluateThrottling { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to evaluate quiet hours.
|
||||
/// </summary>
|
||||
public bool EvaluateQuietHours { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp to use for throttle/quiet hours evaluation (defaults to now).
|
||||
/// </summary>
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a simulation run.
|
||||
/// </summary>
|
||||
public enum NotifySimulationStatus
|
||||
{
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Cronos" Version="0.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of storm breaker using in-memory tracking.
|
||||
/// </summary>
|
||||
public sealed class DefaultStormBreaker : IStormBreaker
|
||||
{
|
||||
private readonly StormBreakerConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultStormBreaker> _logger;
|
||||
|
||||
// In-memory storm tracking (keyed by storm key)
|
||||
private readonly ConcurrentDictionary<string, StormTracker> _storms = new();
|
||||
|
||||
public DefaultStormBreaker(
|
||||
IOptions<StormBreakerConfig> config,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultStormBreaker> logger)
|
||||
{
|
||||
_config = config?.Value ?? new StormBreakerConfig();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<StormDetectionResult> DetectAsync(
|
||||
string tenantId,
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
if (!_config.Enabled)
|
||||
{
|
||||
return Task.FromResult(new StormDetectionResult
|
||||
{
|
||||
Decision = StormDecision.DeliverNormally,
|
||||
Reason = "Storm breaking disabled"
|
||||
});
|
||||
}
|
||||
|
||||
var stormKey = ComputeStormKey(tenantId, @event.Kind, rule.RuleId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var tracker = _storms.GetOrAdd(stormKey, _ => new StormTracker
|
||||
{
|
||||
StormKey = stormKey,
|
||||
TenantId = tenantId,
|
||||
EventKind = @event.Kind,
|
||||
RuleId = rule.RuleId,
|
||||
WindowStart = now
|
||||
});
|
||||
|
||||
// Clean up old events outside the detection window
|
||||
CleanupOldEvents(tracker, now);
|
||||
|
||||
var eventCount = tracker.EventTimestamps.Count;
|
||||
|
||||
// Check if we're in storm mode
|
||||
if (eventCount >= _config.StormThreshold)
|
||||
{
|
||||
// Check if we should send a summary
|
||||
var shouldSendSummary = tracker.LastSummaryAt is null ||
|
||||
(now - tracker.LastSummaryAt.Value) >= _config.SummaryInterval;
|
||||
|
||||
if (shouldSendSummary)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Storm detected for {StormKey}: {EventCount} events in window, triggering summary",
|
||||
stormKey, eventCount);
|
||||
|
||||
return Task.FromResult(new StormDetectionResult
|
||||
{
|
||||
Decision = StormDecision.SendSummary,
|
||||
StormKey = stormKey,
|
||||
Reason = $"Storm threshold ({_config.StormThreshold}) reached with {eventCount} events",
|
||||
AccumulatedCount = eventCount,
|
||||
Threshold = _config.StormThreshold,
|
||||
WindowStart = tracker.WindowStart
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Storm active for {StormKey}: {EventCount} events, summary sent at {LastSummaryAt}",
|
||||
stormKey, eventCount, tracker.LastSummaryAt);
|
||||
|
||||
return Task.FromResult(new StormDetectionResult
|
||||
{
|
||||
Decision = StormDecision.SuppressedBySummary,
|
||||
StormKey = stormKey,
|
||||
Reason = $"Storm active, summary already sent at {tracker.LastSummaryAt}",
|
||||
AccumulatedCount = eventCount,
|
||||
Threshold = _config.StormThreshold,
|
||||
WindowStart = tracker.WindowStart,
|
||||
NextSummaryAt = tracker.LastSummaryAt?.Add(_config.SummaryInterval)
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we're approaching storm threshold
|
||||
if (eventCount >= _config.StormThreshold - 1)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Storm threshold approaching for {StormKey}: {EventCount} events",
|
||||
stormKey, eventCount);
|
||||
|
||||
return Task.FromResult(new StormDetectionResult
|
||||
{
|
||||
Decision = StormDecision.SuppressAndAccumulate,
|
||||
StormKey = stormKey,
|
||||
Reason = $"Approaching storm threshold ({eventCount + 1}/{_config.StormThreshold})",
|
||||
AccumulatedCount = eventCount,
|
||||
Threshold = _config.StormThreshold,
|
||||
WindowStart = tracker.WindowStart
|
||||
});
|
||||
}
|
||||
|
||||
// Normal delivery
|
||||
return Task.FromResult(new StormDetectionResult
|
||||
{
|
||||
Decision = StormDecision.DeliverNormally,
|
||||
StormKey = stormKey,
|
||||
AccumulatedCount = eventCount,
|
||||
Threshold = _config.StormThreshold,
|
||||
WindowStart = tracker.WindowStart
|
||||
});
|
||||
}
|
||||
|
||||
public Task RecordEventAsync(
|
||||
string tenantId,
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
|
||||
var stormKey = ComputeStormKey(tenantId, @event.Kind, rule.RuleId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var tracker = _storms.GetOrAdd(stormKey, _ => new StormTracker
|
||||
{
|
||||
StormKey = stormKey,
|
||||
TenantId = tenantId,
|
||||
EventKind = @event.Kind,
|
||||
RuleId = rule.RuleId,
|
||||
WindowStart = now
|
||||
});
|
||||
|
||||
// Add event timestamp
|
||||
tracker.EventTimestamps.Add(now);
|
||||
tracker.LastEventAt = now;
|
||||
|
||||
// Track sample event IDs
|
||||
if (tracker.SampleEventIds.Count < _config.MaxSampleEvents)
|
||||
{
|
||||
tracker.SampleEventIds.Add(@event.EventId.ToString("N"));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded event {EventId} for storm {StormKey}, count: {Count}",
|
||||
@event.EventId, stormKey, tracker.EventTimestamps.Count);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<StormSummary?> TriggerSummaryAsync(
|
||||
string tenantId,
|
||||
string stormKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stormKey);
|
||||
|
||||
if (!_storms.TryGetValue(stormKey, out var tracker))
|
||||
{
|
||||
return Task.FromResult<StormSummary?>(null);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
CleanupOldEvents(tracker, now);
|
||||
|
||||
var summary = new StormSummary
|
||||
{
|
||||
SummaryId = Guid.NewGuid().ToString("N"),
|
||||
StormKey = stormKey,
|
||||
TenantId = tenantId,
|
||||
EventCount = tracker.EventTimestamps.Count,
|
||||
EventKind = tracker.EventKind,
|
||||
RuleId = tracker.RuleId,
|
||||
WindowStart = tracker.WindowStart,
|
||||
WindowEnd = now,
|
||||
SampleEventIds = tracker.SampleEventIds.ToArray(),
|
||||
GeneratedAt = now
|
||||
};
|
||||
|
||||
// Update tracker state
|
||||
tracker.LastSummaryAt = now;
|
||||
tracker.SummaryCount++;
|
||||
|
||||
// Reset window for next batch
|
||||
tracker.WindowStart = now;
|
||||
tracker.EventTimestamps.Clear();
|
||||
tracker.SampleEventIds.Clear();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated storm summary {SummaryId} for {StormKey}: {EventCount} events",
|
||||
summary.SummaryId, stormKey, summary.EventCount);
|
||||
|
||||
return Task.FromResult<StormSummary?>(summary);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StormState>> GetActiveStormsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var activeStorms = new List<StormState>();
|
||||
|
||||
foreach (var tracker in _storms.Values)
|
||||
{
|
||||
if (tracker.TenantId != tenantId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CleanupOldEvents(tracker, now);
|
||||
|
||||
if (tracker.EventTimestamps.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
activeStorms.Add(new StormState
|
||||
{
|
||||
StormKey = tracker.StormKey,
|
||||
TenantId = tracker.TenantId,
|
||||
EventKind = tracker.EventKind,
|
||||
RuleId = tracker.RuleId,
|
||||
EventCount = tracker.EventTimestamps.Count,
|
||||
WindowStart = tracker.WindowStart,
|
||||
LastEventAt = tracker.LastEventAt,
|
||||
LastSummaryAt = tracker.LastSummaryAt,
|
||||
SummaryCount = tracker.SummaryCount
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<StormState>>(activeStorms);
|
||||
}
|
||||
|
||||
private void CleanupOldEvents(StormTracker tracker, DateTimeOffset now)
|
||||
{
|
||||
var cutoff = now - _config.DetectionWindow;
|
||||
tracker.EventTimestamps.RemoveAll(t => t < cutoff);
|
||||
|
||||
// Reset window if all events expired
|
||||
if (tracker.EventTimestamps.Count == 0)
|
||||
{
|
||||
tracker.WindowStart = now;
|
||||
tracker.SampleEventIds.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeStormKey(string tenantId, string eventKind, string ruleId)
|
||||
{
|
||||
return $"{tenantId}:{eventKind}:{ruleId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal tracker for storm state.
|
||||
/// </summary>
|
||||
private sealed class StormTracker
|
||||
{
|
||||
public required string StormKey { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventKind { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public DateTimeOffset WindowStart { get; set; }
|
||||
public DateTimeOffset LastEventAt { get; set; }
|
||||
public DateTimeOffset? LastSummaryAt { get; set; }
|
||||
public int SummaryCount { get; set; }
|
||||
public List<DateTimeOffset> EventTimestamps { get; } = [];
|
||||
public List<string> SampleEventIds { get; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
/// <summary>
|
||||
/// Storm breaker service that detects high-volume notification storms
|
||||
/// and converts them to summary notifications to prevent recipient flooding.
|
||||
/// </summary>
|
||||
public interface IStormBreaker
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates an event to determine if it's part of a notification storm.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="event">The notification event.</param>
|
||||
/// <param name="rule">The matched rule.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Storm detection result with decision and context.</returns>
|
||||
Task<StormDetectionResult> DetectAsync(
|
||||
string tenantId,
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records an event occurrence for storm tracking.
|
||||
/// </summary>
|
||||
Task RecordEventAsync(
|
||||
string tenantId,
|
||||
NotifyEvent @event,
|
||||
NotifyRule rule,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a summary notification for accumulated storm events.
|
||||
/// </summary>
|
||||
Task<StormSummary?> TriggerSummaryAsync(
|
||||
string tenantId,
|
||||
string stormKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active storms for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<StormState>> GetActiveStormsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of storm detection evaluation.
|
||||
/// </summary>
|
||||
public sealed record StormDetectionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision made by the storm breaker.
|
||||
/// </summary>
|
||||
public required StormDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique key identifying this storm.
|
||||
/// </summary>
|
||||
public string? StormKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of events accumulated in the current storm window.
|
||||
/// </summary>
|
||||
public int AccumulatedCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Threshold that triggered storm detection.
|
||||
/// </summary>
|
||||
public int Threshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the storm window started.
|
||||
/// </summary>
|
||||
public DateTimeOffset? WindowStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next summary will be sent.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextSummaryAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision made by the storm breaker.
|
||||
/// </summary>
|
||||
public enum StormDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// No storm detected, deliver normally.
|
||||
/// </summary>
|
||||
DeliverNormally,
|
||||
|
||||
/// <summary>
|
||||
/// Storm detected, suppress individual delivery and accumulate.
|
||||
/// </summary>
|
||||
SuppressAndAccumulate,
|
||||
|
||||
/// <summary>
|
||||
/// Storm threshold reached, send summary notification.
|
||||
/// </summary>
|
||||
SendSummary,
|
||||
|
||||
/// <summary>
|
||||
/// Storm already handled by recent summary, suppress.
|
||||
/// </summary>
|
||||
SuppressedBySummary
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary notification for a storm.
|
||||
/// </summary>
|
||||
public sealed record StormSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique ID for this summary.
|
||||
/// </summary>
|
||||
public required string SummaryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The storm key this summary covers.
|
||||
/// </summary>
|
||||
public required string StormKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of events summarized.
|
||||
/// </summary>
|
||||
public required int EventCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind being summarized.
|
||||
/// </summary>
|
||||
public required string EventKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule that triggered these events.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the summary window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset WindowStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the summary window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset WindowEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample event IDs (first N events).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SampleEventIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When this summary was generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current state of an active storm.
|
||||
/// </summary>
|
||||
public sealed record StormState
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique key identifying this storm.
|
||||
/// </summary>
|
||||
public required string StormKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind.
|
||||
/// </summary>
|
||||
public required string EventKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule ID.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current event count in this storm.
|
||||
/// </summary>
|
||||
public required int EventCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the storm window started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset WindowStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the last event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset LastEventAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the last summary was sent.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastSummaryAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of summaries sent for this storm.
|
||||
/// </summary>
|
||||
public int SummaryCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for storm breaker behavior.
|
||||
/// </summary>
|
||||
public sealed record StormBreakerConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of events in a window that triggers storm mode.
|
||||
/// </summary>
|
||||
public int StormThreshold { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Time window for counting events.
|
||||
/// </summary>
|
||||
public TimeSpan DetectionWindow { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// How often to send summary notifications during a storm.
|
||||
/// </summary>
|
||||
public TimeSpan SummaryInterval { get; init; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of sample event IDs to include in summary.
|
||||
/// </summary>
|
||||
public int MaxSampleEvents { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether storm breaking is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ public enum NotifyChannelType
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,4 +71,8 @@ public enum NotifyDeliveryFormat
|
||||
Email,
|
||||
Webhook,
|
||||
Json,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
Cli,
|
||||
InAppInbox,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Escalation policy defining how incidents are escalated through multiple levels.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationPolicy
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationPolicy(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
ImmutableArray<NotifyEscalationLevel> levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Levels = NormalizeLevels(levels);
|
||||
|
||||
if (Levels.IsDefaultOrEmpty)
|
||||
{
|
||||
throw new ArgumentException("At least one escalation level is required.", nameof(levels));
|
||||
}
|
||||
|
||||
Enabled = enabled;
|
||||
RepeatEnabled = repeatEnabled;
|
||||
RepeatCount = repeatCount is > 0 ? repeatCount : null;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyEscalationPolicy Create(
|
||||
string policyId,
|
||||
string tenantId,
|
||||
string name,
|
||||
IEnumerable<NotifyEscalationLevel>? levels,
|
||||
bool enabled = true,
|
||||
bool repeatEnabled = false,
|
||||
int? repeatCount = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyEscalationPolicy(
|
||||
policyId,
|
||||
tenantId,
|
||||
name,
|
||||
ToImmutableArray(levels),
|
||||
enabled,
|
||||
repeatEnabled,
|
||||
repeatCount,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of escalation levels.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationLevel> Levels { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to repeat the escalation cycle after reaching the last level.
|
||||
/// </summary>
|
||||
public bool RepeatEnabled { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of times to repeat the escalation cycle.
|
||||
/// </summary>
|
||||
public int? RepeatCount { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> NormalizeLevels(ImmutableArray<NotifyEscalationLevel> levels)
|
||||
{
|
||||
if (levels.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationLevel>.Empty;
|
||||
}
|
||||
|
||||
return levels
|
||||
.Where(static l => l is not null)
|
||||
.OrderBy(static l => l.Order)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationLevel> ToImmutableArray(IEnumerable<NotifyEscalationLevel>? levels)
|
||||
=> levels is null ? ImmutableArray<NotifyEscalationLevel>.Empty : levels.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single level in an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationLevel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationLevel(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
ImmutableArray<NotifyEscalationTarget> targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
Order = order >= 0 ? order : 0;
|
||||
EscalateAfter = escalateAfter > TimeSpan.Zero ? escalateAfter : TimeSpan.FromMinutes(15);
|
||||
Targets = NormalizeTargets(targets);
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
NotifyAll = notifyAll;
|
||||
}
|
||||
|
||||
public static NotifyEscalationLevel Create(
|
||||
int order,
|
||||
TimeSpan escalateAfter,
|
||||
IEnumerable<NotifyEscalationTarget>? targets,
|
||||
string? name = null,
|
||||
bool notifyAll = true)
|
||||
{
|
||||
return new NotifyEscalationLevel(
|
||||
order,
|
||||
escalateAfter,
|
||||
ToImmutableArray(targets),
|
||||
name,
|
||||
notifyAll);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order of this level in the escalation chain (0-based).
|
||||
/// </summary>
|
||||
public int Order { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Time to wait before escalating to this level.
|
||||
/// </summary>
|
||||
public TimeSpan EscalateAfter { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Targets to notify at this level.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationTarget> Targets { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional name for this level (e.g., "Primary", "Secondary", "Management").
|
||||
/// </summary>
|
||||
public string? Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to notify all targets at this level, or just the first available.
|
||||
/// </summary>
|
||||
public bool NotifyAll { get; }
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> NormalizeTargets(ImmutableArray<NotifyEscalationTarget> targets)
|
||||
{
|
||||
if (targets.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<NotifyEscalationTarget>.Empty;
|
||||
}
|
||||
|
||||
return targets
|
||||
.Where(static t => t is not null)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotifyEscalationTarget> ToImmutableArray(IEnumerable<NotifyEscalationTarget>? targets)
|
||||
=> targets is null ? ImmutableArray<NotifyEscalationTarget>.Empty : targets.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target to notify during escalation.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationTarget
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationTarget(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
Type = type;
|
||||
TargetId = NotifyValidation.EnsureNotNullOrWhiteSpace(targetId, nameof(targetId));
|
||||
ChannelOverride = NotifyValidation.TrimToNull(channelOverride);
|
||||
}
|
||||
|
||||
public static NotifyEscalationTarget Create(
|
||||
NotifyEscalationTargetType type,
|
||||
string targetId,
|
||||
string? channelOverride = null)
|
||||
{
|
||||
return new NotifyEscalationTarget(type, targetId, channelOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of target (user, on-call schedule, channel, external service).
|
||||
/// </summary>
|
||||
public NotifyEscalationTargetType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// ID of the target (user ID, schedule ID, channel ID, or external service ID).
|
||||
/// </summary>
|
||||
public string TargetId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel override for this target.
|
||||
/// </summary>
|
||||
public string? ChannelOverride { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of escalation target.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationTargetType
|
||||
{
|
||||
/// <summary>
|
||||
/// A specific user.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// An on-call schedule (resolves to current on-call user).
|
||||
/// </summary>
|
||||
OnCallSchedule,
|
||||
|
||||
/// <summary>
|
||||
/// A notification channel directly.
|
||||
/// </summary>
|
||||
Channel,
|
||||
|
||||
/// <summary>
|
||||
/// External service (PagerDuty, OpsGenie, etc.).
|
||||
/// </summary>
|
||||
ExternalService,
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox notification.
|
||||
/// </summary>
|
||||
InAppInbox
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the current state of an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationState
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationState(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel,
|
||||
int repeatIteration,
|
||||
NotifyEscalationStatus status,
|
||||
ImmutableArray<NotifyEscalationAttempt> attempts,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
StateId = NotifyValidation.EnsureNotNullOrWhiteSpace(stateId, nameof(stateId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
IncidentId = NotifyValidation.EnsureNotNullOrWhiteSpace(incidentId, nameof(incidentId));
|
||||
PolicyId = NotifyValidation.EnsureNotNullOrWhiteSpace(policyId, nameof(policyId));
|
||||
CurrentLevel = currentLevel >= 0 ? currentLevel : 0;
|
||||
RepeatIteration = repeatIteration >= 0 ? repeatIteration : 0;
|
||||
Status = status;
|
||||
Attempts = attempts.IsDefault ? ImmutableArray<NotifyEscalationAttempt>.Empty : attempts;
|
||||
NextEscalationAt = NotifyValidation.EnsureUtc(nextEscalationAt);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
AcknowledgedAt = NotifyValidation.EnsureUtc(acknowledgedAt);
|
||||
AcknowledgedBy = NotifyValidation.TrimToNull(acknowledgedBy);
|
||||
ResolvedAt = NotifyValidation.EnsureUtc(resolvedAt);
|
||||
ResolvedBy = NotifyValidation.TrimToNull(resolvedBy);
|
||||
}
|
||||
|
||||
public static NotifyEscalationState Create(
|
||||
string stateId,
|
||||
string tenantId,
|
||||
string incidentId,
|
||||
string policyId,
|
||||
int currentLevel = 0,
|
||||
int repeatIteration = 0,
|
||||
NotifyEscalationStatus status = NotifyEscalationStatus.Active,
|
||||
IEnumerable<NotifyEscalationAttempt>? attempts = null,
|
||||
DateTimeOffset? nextEscalationAt = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? updatedAt = null,
|
||||
DateTimeOffset? acknowledgedAt = null,
|
||||
string? acknowledgedBy = null,
|
||||
DateTimeOffset? resolvedAt = null,
|
||||
string? resolvedBy = null)
|
||||
{
|
||||
return new NotifyEscalationState(
|
||||
stateId,
|
||||
tenantId,
|
||||
incidentId,
|
||||
policyId,
|
||||
currentLevel,
|
||||
repeatIteration,
|
||||
status,
|
||||
attempts?.ToImmutableArray() ?? ImmutableArray<NotifyEscalationAttempt>.Empty,
|
||||
nextEscalationAt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
acknowledgedAt,
|
||||
acknowledgedBy,
|
||||
resolvedAt,
|
||||
resolvedBy);
|
||||
}
|
||||
|
||||
public string StateId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string IncidentId { get; }
|
||||
|
||||
public string PolicyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current escalation level (0-based index).
|
||||
/// </summary>
|
||||
public int CurrentLevel { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current repeat iteration (0 = first pass through levels).
|
||||
/// </summary>
|
||||
public int RepeatIteration { get; }
|
||||
|
||||
public NotifyEscalationStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// History of escalation attempts.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyEscalationAttempt> Attempts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next escalation will occur.
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextEscalationAt { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
public DateTimeOffset? AcknowledgedAt { get; }
|
||||
|
||||
public string? AcknowledgedBy { get; }
|
||||
|
||||
public DateTimeOffset? ResolvedAt { get; }
|
||||
|
||||
public string? ResolvedBy { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a single escalation attempt.
|
||||
/// </summary>
|
||||
public sealed record NotifyEscalationAttempt
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyEscalationAttempt(
|
||||
int level,
|
||||
int iteration,
|
||||
DateTimeOffset timestamp,
|
||||
ImmutableArray<string> notifiedTargets,
|
||||
bool success,
|
||||
string? failureReason = null)
|
||||
{
|
||||
Level = level >= 0 ? level : 0;
|
||||
Iteration = iteration >= 0 ? iteration : 0;
|
||||
Timestamp = NotifyValidation.EnsureUtc(timestamp);
|
||||
NotifiedTargets = notifiedTargets.IsDefault ? ImmutableArray<string>.Empty : notifiedTargets;
|
||||
Success = success;
|
||||
FailureReason = NotifyValidation.TrimToNull(failureReason);
|
||||
}
|
||||
|
||||
public int Level { get; }
|
||||
|
||||
public int Iteration { get; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public ImmutableArray<string> NotifiedTargets { get; }
|
||||
|
||||
public bool Success { get; }
|
||||
|
||||
public string? FailureReason { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an escalation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyEscalationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Escalation is active and being processed.
|
||||
/// </summary>
|
||||
Active,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was acknowledged.
|
||||
/// </summary>
|
||||
Acknowledged,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was resolved.
|
||||
/// </summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation exhausted all levels and repeats.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// Escalation was manually suppressed.
|
||||
/// </summary>
|
||||
Suppressed
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A localization bundle containing translated strings for a specific locale.
|
||||
/// </summary>
|
||||
public sealed record NotifyLocalizationBundle
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyLocalizationBundle(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
ImmutableDictionary<string, string> strings,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
BundleId = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleId, nameof(bundleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
|
||||
BundleKey = NotifyValidation.EnsureNotNullOrWhiteSpace(bundleKey, nameof(bundleKey));
|
||||
Strings = strings;
|
||||
IsDefault = isDefault;
|
||||
ParentLocale = NormalizeParentLocale(parentLocale, Locale);
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyLocalizationBundle Create(
|
||||
string bundleId,
|
||||
string tenantId,
|
||||
string locale,
|
||||
string bundleKey,
|
||||
IEnumerable<KeyValuePair<string, string>>? strings = null,
|
||||
bool isDefault = false,
|
||||
string? parentLocale = null,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyLocalizationBundle(
|
||||
bundleId,
|
||||
tenantId,
|
||||
locale,
|
||||
bundleKey,
|
||||
ToImmutableDictionary(strings) ?? ImmutableDictionary<string, string>.Empty,
|
||||
isDefault,
|
||||
parentLocale,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this bundle.
|
||||
/// </summary>
|
||||
public string BundleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID this bundle belongs to.
|
||||
/// </summary>
|
||||
public string TenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code (e.g., "en-us", "fr-fr", "ja-jp").
|
||||
/// </summary>
|
||||
public string Locale { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle key for grouping related bundles (e.g., "notifications", "email-subjects").
|
||||
/// </summary>
|
||||
public string BundleKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of string key to translated value.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Strings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default/fallback bundle for the bundle key.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent locale for fallback chain (e.g., "en" for "en-us").
|
||||
/// Automatically computed if not specified.
|
||||
/// </summary>
|
||||
public string? ParentLocale { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key.
|
||||
/// </summary>
|
||||
public string? GetString(string key)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a localized string by key with a default fallback.
|
||||
/// </summary>
|
||||
public string GetString(string key, string defaultValue)
|
||||
{
|
||||
return Strings.TryGetValue(key, out var value) ? value : defaultValue;
|
||||
}
|
||||
|
||||
private static string? NormalizeParentLocale(string? parentLocale, string locale)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(parentLocale))
|
||||
{
|
||||
return parentLocale.ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Auto-compute parent locale from locale
|
||||
// e.g., "en-us" -> "en", "pt-br" -> "pt"
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
return locale[..dashIndex];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving localized strings with fallback chain support.
|
||||
/// </summary>
|
||||
public interface ILocalizationResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a localized string using the fallback chain.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="bundleKey">The bundle key.</param>
|
||||
/// <param name="stringKey">The string key within the bundle.</param>
|
||||
/// <param name="locale">The preferred locale.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The resolved string or null if not found.</returns>
|
||||
Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves multiple strings at once for efficiency.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a localization resolution.
|
||||
/// </summary>
|
||||
public sealed record LocalizedString
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved string value.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The locale that provided the value.
|
||||
/// </summary>
|
||||
public required string ResolvedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The originally requested locale.
|
||||
/// </summary>
|
||||
public required string RequestedLocale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether fallback was used.
|
||||
/// </summary>
|
||||
public bool UsedFallback => !ResolvedLocale.Equals(RequestedLocale, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The fallback chain that was traversed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FallbackChain { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// On-call schedule defining who is on-call at any given time.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
ImmutableArray<NotifyOnCallLayer> layers,
|
||||
ImmutableArray<NotifyOnCallOverride> overrides,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
Layers = layers.IsDefault ? ImmutableArray<NotifyOnCallLayer>.Empty : layers;
|
||||
Overrides = overrides.IsDefault ? ImmutableArray<NotifyOnCallOverride>.Empty : overrides;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyOnCallSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string timeZone,
|
||||
IEnumerable<NotifyOnCallLayer>? layers = null,
|
||||
IEnumerable<NotifyOnCallOverride>? overrides = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyOnCallSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
timeZone,
|
||||
layers?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallLayer>.Empty,
|
||||
overrides?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallOverride>.Empty,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for the schedule (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation layers that make up this schedule.
|
||||
/// Multiple layers are combined to determine final on-call.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallLayer> Layers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Temporary overrides (e.g., vacation coverage).
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallOverride> Overrides { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer in an on-call schedule representing a rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallLayer
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallLayer(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> participants,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
LayerId = NotifyValidation.EnsureNotNullOrWhiteSpace(layerId, nameof(layerId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
Priority = priority;
|
||||
RotationType = rotationType;
|
||||
RotationInterval = rotationInterval > TimeSpan.Zero ? rotationInterval : TimeSpan.FromDays(7);
|
||||
RotationStartsAt = NotifyValidation.EnsureUtc(rotationStartsAt);
|
||||
Participants = participants.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : participants;
|
||||
Restrictions = restrictions;
|
||||
}
|
||||
|
||||
public static NotifyOnCallLayer Create(
|
||||
string layerId,
|
||||
string name,
|
||||
int priority,
|
||||
NotifyRotationType rotationType,
|
||||
TimeSpan rotationInterval,
|
||||
DateTimeOffset rotationStartsAt,
|
||||
IEnumerable<NotifyOnCallParticipant>? participants = null,
|
||||
NotifyOnCallRestriction? restrictions = null)
|
||||
{
|
||||
return new NotifyOnCallLayer(
|
||||
layerId,
|
||||
name,
|
||||
priority,
|
||||
rotationType,
|
||||
rotationInterval,
|
||||
rotationStartsAt,
|
||||
participants?.ToImmutableArray() ?? ImmutableArray<NotifyOnCallParticipant>.Empty,
|
||||
restrictions);
|
||||
}
|
||||
|
||||
public string LayerId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Higher priority layers take precedence when determining who is on-call.
|
||||
/// </summary>
|
||||
public int Priority { get; }
|
||||
|
||||
public NotifyRotationType RotationType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of each rotation (e.g., 1 week).
|
||||
/// </summary>
|
||||
public TimeSpan RotationInterval { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the rotation schedule started.
|
||||
/// </summary>
|
||||
public DateTimeOffset RotationStartsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Participants in the rotation.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotifyOnCallParticipant> Participants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional time restrictions for when this layer is active.
|
||||
/// </summary>
|
||||
public NotifyOnCallRestriction? Restrictions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Participant in an on-call rotation.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallParticipant
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallParticipant(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
ImmutableArray<NotifyContactMethod> contactMethods = default)
|
||||
{
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
Name = NotifyValidation.TrimToNull(name);
|
||||
Email = NotifyValidation.TrimToNull(email);
|
||||
Phone = NotifyValidation.TrimToNull(phone);
|
||||
ContactMethods = contactMethods.IsDefault ? ImmutableArray<NotifyContactMethod>.Empty : contactMethods;
|
||||
}
|
||||
|
||||
public static NotifyOnCallParticipant Create(
|
||||
string userId,
|
||||
string? name = null,
|
||||
string? email = null,
|
||||
string? phone = null,
|
||||
IEnumerable<NotifyContactMethod>? contactMethods = null)
|
||||
{
|
||||
return new NotifyOnCallParticipant(
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
contactMethods?.ToImmutableArray() ?? ImmutableArray<NotifyContactMethod>.Empty);
|
||||
}
|
||||
|
||||
public string UserId { get; }
|
||||
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Email { get; }
|
||||
|
||||
public string? Phone { get; }
|
||||
|
||||
public ImmutableArray<NotifyContactMethod> ContactMethods { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method for a participant.
|
||||
/// </summary>
|
||||
public sealed record NotifyContactMethod
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyContactMethod(
|
||||
NotifyContactMethodType type,
|
||||
string address,
|
||||
int priority = 0,
|
||||
bool enabled = true)
|
||||
{
|
||||
Type = type;
|
||||
Address = NotifyValidation.EnsureNotNullOrWhiteSpace(address, nameof(address));
|
||||
Priority = priority;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public NotifyContactMethodType Type { get; }
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Priority { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of contact method.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyContactMethodType
|
||||
{
|
||||
Email,
|
||||
Sms,
|
||||
Phone,
|
||||
Slack,
|
||||
Teams,
|
||||
Webhook,
|
||||
InAppInbox,
|
||||
PagerDuty,
|
||||
OpsGenie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of rotation.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Daily rotation.
|
||||
/// </summary>
|
||||
Daily,
|
||||
|
||||
/// <summary>
|
||||
/// Weekly rotation.
|
||||
/// </summary>
|
||||
Weekly,
|
||||
|
||||
/// <summary>
|
||||
/// Custom interval rotation.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time restrictions for when an on-call layer is active.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallRestriction
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallRestriction(
|
||||
NotifyRestrictionType type,
|
||||
ImmutableArray<NotifyTimeRange> timeRanges)
|
||||
{
|
||||
Type = type;
|
||||
TimeRanges = timeRanges.IsDefault ? ImmutableArray<NotifyTimeRange>.Empty : timeRanges;
|
||||
}
|
||||
|
||||
public static NotifyOnCallRestriction Create(
|
||||
NotifyRestrictionType type,
|
||||
IEnumerable<NotifyTimeRange>? timeRanges = null)
|
||||
{
|
||||
return new NotifyOnCallRestriction(
|
||||
type,
|
||||
timeRanges?.ToImmutableArray() ?? ImmutableArray<NotifyTimeRange>.Empty);
|
||||
}
|
||||
|
||||
public NotifyRestrictionType Type { get; }
|
||||
|
||||
public ImmutableArray<NotifyTimeRange> TimeRanges { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of restriction.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyRestrictionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Restrictions apply daily.
|
||||
/// </summary>
|
||||
DailyRestriction,
|
||||
|
||||
/// <summary>
|
||||
/// Restrictions apply weekly on specific days.
|
||||
/// </summary>
|
||||
WeeklyRestriction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time range for restrictions.
|
||||
/// </summary>
|
||||
public sealed record NotifyTimeRange
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyTimeRange(
|
||||
DayOfWeek? dayOfWeek,
|
||||
TimeOnly startTime,
|
||||
TimeOnly endTime)
|
||||
{
|
||||
DayOfWeek = dayOfWeek;
|
||||
StartTime = startTime;
|
||||
EndTime = endTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day of week (null for daily restrictions).
|
||||
/// </summary>
|
||||
public DayOfWeek? DayOfWeek { get; }
|
||||
|
||||
public TimeOnly StartTime { get; }
|
||||
|
||||
public TimeOnly EndTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Temporary override for an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOnCallOverride(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
UserId = NotifyValidation.EnsureNotNullOrWhiteSpace(userId, nameof(userId));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyOnCallOverride Create(
|
||||
string overrideId,
|
||||
string userId,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOnCallOverride(
|
||||
overrideId,
|
||||
userId,
|
||||
startsAt,
|
||||
endsAt,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// User who will be on-call during this override.
|
||||
/// </summary>
|
||||
public string UserId { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp >= StartsAt && timestamp < EndsAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of resolving who is currently on-call.
|
||||
/// </summary>
|
||||
public sealed record NotifyOnCallResolution
|
||||
{
|
||||
public NotifyOnCallResolution(
|
||||
string scheduleId,
|
||||
DateTimeOffset evaluatedAt,
|
||||
ImmutableArray<NotifyOnCallParticipant> onCallUsers,
|
||||
string? sourceLayer = null,
|
||||
string? sourceOverride = null)
|
||||
{
|
||||
ScheduleId = scheduleId;
|
||||
EvaluatedAt = evaluatedAt;
|
||||
OnCallUsers = onCallUsers.IsDefault ? ImmutableArray<NotifyOnCallParticipant>.Empty : onCallUsers;
|
||||
SourceLayer = sourceLayer;
|
||||
SourceOverride = sourceOverride;
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public DateTimeOffset EvaluatedAt { get; }
|
||||
|
||||
public ImmutableArray<NotifyOnCallParticipant> OnCallUsers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The layer that provided the on-call user (if from rotation).
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The override that provided the on-call user (if from override).
|
||||
/// </summary>
|
||||
public string? SourceOverride { get; }
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Quiet hours schedule configuration for suppressing notifications during specified periods.
|
||||
/// </summary>
|
||||
public sealed record NotifyQuietHoursSchedule
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyQuietHoursSchedule(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ScheduleId = NotifyValidation.EnsureNotNullOrWhiteSpace(scheduleId, nameof(scheduleId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
CronExpression = NotifyValidation.EnsureNotNullOrWhiteSpace(cronExpression, nameof(cronExpression));
|
||||
Duration = duration > TimeSpan.Zero ? duration : TimeSpan.FromHours(8);
|
||||
TimeZone = NotifyValidation.EnsureNotNullOrWhiteSpace(timeZone, nameof(timeZone));
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyQuietHoursSchedule Create(
|
||||
string scheduleId,
|
||||
string tenantId,
|
||||
string name,
|
||||
string cronExpression,
|
||||
TimeSpan duration,
|
||||
string timeZone,
|
||||
string? channelId = null,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyQuietHoursSchedule(
|
||||
scheduleId,
|
||||
tenantId,
|
||||
name,
|
||||
cronExpression,
|
||||
duration,
|
||||
timeZone,
|
||||
channelId,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string ScheduleId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression defining when quiet hours start.
|
||||
/// </summary>
|
||||
public string CronExpression { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the quiet hours window.
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// IANA time zone for evaluating the cron expression (e.g., "America/New_York").
|
||||
/// </summary>
|
||||
public string TimeZone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope quiet hours to a specific channel.
|
||||
/// If null, applies to all channels.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance window for planned suppression of notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyMaintenanceWindow
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyMaintenanceWindow(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
ImmutableArray<string> channelIds = default,
|
||||
ImmutableArray<string> ruleIds = default,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
WindowId = NotifyValidation.EnsureNotNullOrWhiteSpace(windowId, nameof(windowId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
StartsAt = NotifyValidation.EnsureUtc(startsAt);
|
||||
EndsAt = NotifyValidation.EnsureUtc(endsAt);
|
||||
SuppressNotifications = suppressNotifications;
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
ChannelIds = NormalizeStringArray(channelIds);
|
||||
RuleIds = NormalizeStringArray(ruleIds);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
|
||||
if (EndsAt <= StartsAt)
|
||||
{
|
||||
throw new ArgumentException("EndsAt must be after StartsAt.", nameof(endsAt));
|
||||
}
|
||||
}
|
||||
|
||||
public static NotifyMaintenanceWindow Create(
|
||||
string windowId,
|
||||
string tenantId,
|
||||
string name,
|
||||
DateTimeOffset startsAt,
|
||||
DateTimeOffset endsAt,
|
||||
bool suppressNotifications = true,
|
||||
string? reason = null,
|
||||
IEnumerable<string>? channelIds = null,
|
||||
IEnumerable<string>? ruleIds = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyMaintenanceWindow(
|
||||
windowId,
|
||||
tenantId,
|
||||
name,
|
||||
startsAt,
|
||||
endsAt,
|
||||
suppressNotifications,
|
||||
reason,
|
||||
ToImmutableArray(channelIds),
|
||||
ToImmutableArray(ruleIds),
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
public string WindowId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public DateTimeOffset StartsAt { get; }
|
||||
|
||||
public DateTimeOffset EndsAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to suppress notifications during the maintenance window.
|
||||
/// </summary>
|
||||
public bool SuppressNotifications { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the maintenance window.
|
||||
/// </summary>
|
||||
public string? Reason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of channel IDs to scope the maintenance window.
|
||||
/// If empty, applies to all channels.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ChannelIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional list of rule IDs to scope the maintenance window.
|
||||
/// If empty, applies to all rules.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> RuleIds { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the maintenance window is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> SuppressNotifications && timestamp >= StartsAt && timestamp < EndsAt;
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(static v => v.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
|
||||
=> values is null ? ImmutableArray<string>.Empty : values.ToImmutableArray();
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator override for quiet hours or throttle configuration.
|
||||
/// Allows an operator to temporarily bypass quiet hours or throttling.
|
||||
/// </summary>
|
||||
public sealed record NotifyOperatorOverride
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyOperatorOverride(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
OverrideId = NotifyValidation.EnsureNotNullOrWhiteSpace(overrideId, nameof(overrideId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
OverrideType = overrideType;
|
||||
ExpiresAt = NotifyValidation.EnsureUtc(expiresAt);
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
RuleId = NotifyValidation.TrimToNull(ruleId);
|
||||
Reason = NotifyValidation.TrimToNull(reason);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public static NotifyOperatorOverride Create(
|
||||
string overrideId,
|
||||
string tenantId,
|
||||
NotifyOverrideType overrideType,
|
||||
DateTimeOffset expiresAt,
|
||||
string? channelId = null,
|
||||
string? ruleId = null,
|
||||
string? reason = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new NotifyOperatorOverride(
|
||||
overrideId,
|
||||
tenantId,
|
||||
overrideType,
|
||||
expiresAt,
|
||||
channelId,
|
||||
ruleId,
|
||||
reason,
|
||||
createdBy,
|
||||
createdAt);
|
||||
}
|
||||
|
||||
public string OverrideId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public NotifyOverrideType OverrideType { get; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the override.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional rule ID to scope the override.
|
||||
/// </summary>
|
||||
public string? RuleId { get; }
|
||||
|
||||
public string? Reason { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the override is active at the specified time.
|
||||
/// </summary>
|
||||
public bool IsActiveAt(DateTimeOffset timestamp)
|
||||
=> timestamp < ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of operator override.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyOverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// Bypass quiet hours.
|
||||
/// </summary>
|
||||
BypassQuietHours,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass throttling.
|
||||
/// </summary>
|
||||
BypassThrottle,
|
||||
|
||||
/// <summary>
|
||||
/// Bypass maintenance window.
|
||||
/// </summary>
|
||||
BypassMaintenance,
|
||||
|
||||
/// <summary>
|
||||
/// Force suppress notifications.
|
||||
/// </summary>
|
||||
ForceSuppression
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Throttle configuration for rate-limiting notifications.
|
||||
/// </summary>
|
||||
public sealed record NotifyThrottleConfig
|
||||
{
|
||||
[JsonConstructor]
|
||||
public NotifyThrottleConfig(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
ImmutableDictionary<string, string>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
ConfigId = NotifyValidation.EnsureNotNullOrWhiteSpace(configId, nameof(configId));
|
||||
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
|
||||
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
|
||||
DefaultWindow = defaultWindow > TimeSpan.Zero ? defaultWindow : TimeSpan.FromMinutes(5);
|
||||
MaxNotificationsPerWindow = maxNotificationsPerWindow > 0 ? maxNotificationsPerWindow : null;
|
||||
ChannelId = NotifyValidation.TrimToNull(channelId);
|
||||
IsDefault = isDefault;
|
||||
Enabled = enabled;
|
||||
Description = NotifyValidation.TrimToNull(description);
|
||||
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
|
||||
CreatedBy = NotifyValidation.TrimToNull(createdBy);
|
||||
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
|
||||
UpdatedBy = NotifyValidation.TrimToNull(updatedBy);
|
||||
UpdatedAt = NotifyValidation.EnsureUtc(updatedAt ?? CreatedAt);
|
||||
}
|
||||
|
||||
public static NotifyThrottleConfig Create(
|
||||
string configId,
|
||||
string tenantId,
|
||||
string name,
|
||||
TimeSpan defaultWindow,
|
||||
int? maxNotificationsPerWindow = null,
|
||||
string? channelId = null,
|
||||
bool isDefault = false,
|
||||
bool enabled = true,
|
||||
string? description = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? metadata = null,
|
||||
string? createdBy = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
string? updatedBy = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
return new NotifyThrottleConfig(
|
||||
configId,
|
||||
tenantId,
|
||||
name,
|
||||
defaultWindow,
|
||||
maxNotificationsPerWindow,
|
||||
channelId,
|
||||
isDefault,
|
||||
enabled,
|
||||
description,
|
||||
ToImmutableDictionary(metadata),
|
||||
createdBy,
|
||||
createdAt,
|
||||
updatedBy,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default throttle configuration for a tenant.
|
||||
/// </summary>
|
||||
public static NotifyThrottleConfig CreateDefault(
|
||||
string tenantId,
|
||||
TimeSpan? defaultWindow = null,
|
||||
string? createdBy = null)
|
||||
{
|
||||
return Create(
|
||||
configId: $"{tenantId}-default",
|
||||
tenantId: tenantId,
|
||||
name: "Default Throttle",
|
||||
defaultWindow: defaultWindow ?? TimeSpan.FromMinutes(5),
|
||||
maxNotificationsPerWindow: null,
|
||||
channelId: null,
|
||||
isDefault: true,
|
||||
enabled: true,
|
||||
description: "Default throttle configuration for the tenant.",
|
||||
metadata: null,
|
||||
createdBy: createdBy);
|
||||
}
|
||||
|
||||
public string ConfigId { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Default throttle window duration. Notifications with the same correlation key
|
||||
/// within this window will be deduplicated.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional maximum number of notifications allowed per window.
|
||||
/// If set, additional notifications beyond this limit will be suppressed.
|
||||
/// </summary>
|
||||
public int? MaxNotificationsPerWindow { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional channel ID to scope the throttle configuration.
|
||||
/// If null, applies to all channels or serves as the tenant default.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the default throttle configuration for the tenant.
|
||||
/// </summary>
|
||||
public bool IsDefault { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public string? CreatedBy { get; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
public string? UpdatedBy { get; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; }
|
||||
|
||||
private static ImmutableDictionary<string, string>? ToImmutableDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
|
||||
{
|
||||
if (pairs is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,34 @@ public sealed class NotifyMongoOptions
|
||||
|
||||
public string DeliveriesCollection { get; set; } = "deliveries";
|
||||
|
||||
public string DigestsCollection { get; set; } = "digests";
|
||||
|
||||
public string PackApprovalsCollection { get; set; } = "pack_approvals";
|
||||
|
||||
public string LocksCollection { get; set; } = "locks";
|
||||
public string DigestsCollection { get; set; } = "digests";
|
||||
|
||||
public string PackApprovalsCollection { get; set; } = "pack_approvals";
|
||||
|
||||
public string LocksCollection { get; set; } = "locks";
|
||||
|
||||
public string AuditCollection { get; set; } = "audit";
|
||||
|
||||
public string MigrationsCollection { get; set; } = "_notify_migrations";
|
||||
|
||||
public string QuietHoursCollection { get; set; } = "quiet_hours";
|
||||
|
||||
public string MaintenanceWindowsCollection { get; set; } = "maintenance_windows";
|
||||
|
||||
public string ThrottleConfigsCollection { get; set; } = "throttle_configs";
|
||||
|
||||
public string OperatorOverridesCollection { get; set; } = "operator_overrides";
|
||||
|
||||
public string EscalationPoliciesCollection { get; set; } = "escalation_policies";
|
||||
|
||||
public string EscalationStatesCollection { get; set; } = "escalation_states";
|
||||
|
||||
public string OnCallSchedulesCollection { get; set; } = "oncall_schedules";
|
||||
|
||||
public string InboxCollection { get; set; } = "inbox";
|
||||
|
||||
public string LocalizationCollection { get; set; } = "localization";
|
||||
|
||||
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);
|
||||
|
||||
public bool UseMajorityReadConcern { get; set; } = true;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing escalation policies.
|
||||
/// </summary>
|
||||
public interface INotifyEscalationPolicyRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an escalation policy by ID.
|
||||
/// </summary>
|
||||
Task<NotifyEscalationPolicy?> GetAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all escalation policies for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
|
||||
string tenantId,
|
||||
bool? enabledOnly = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates an escalation policy.
|
||||
/// </summary>
|
||||
Task UpsertAsync(
|
||||
NotifyEscalationPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an escalation policy.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user