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

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -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.

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.AirGap.Controller.Tests")]

View File

@@ -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;
}
}

View File

@@ -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));
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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" />

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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"));
}

View File

@@ -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>();

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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. |

View 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`.

View File

@@ -0,0 +1,12 @@
{
"scanners": [
{
"name": "mock",
"kind": "mock",
"description": "Deterministic mock scanner used for CI/offline parity",
"parameters": {
"severity_bias": 0.25
}
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,3 @@
38453c9c0e0a90d22d7048d3201bf1b5665eb483e6682db1a7112f8e4f4fa1e6 configs/scanners.json
577f932bbb00dbd596e46b96d5fbb9561506c7730c097e381a6b34de40402329 inputs/sboms/sample-spdx.json
1b54ce4087800cfe1d5ac439c10a1f131b7476b2093b79d8cd0a29169314291f inputs/vex/sample-openvex.json

View 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
1 scanner sbom vex mode run hash finding_count
2 mock sample-spdx.json sample-openvex.json canonical 0 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
3 mock sample-spdx.json sample-openvex.json shuffled 0 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
4 mock sample-spdx.json sample-openvex.json canonical 1 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
5 mock sample-spdx.json sample-openvex.json shuffled 1 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
6 mock sample-spdx.json sample-openvex.json canonical 2 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
7 mock sample-spdx.json sample-openvex.json shuffled 2 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
8 mock sample-spdx.json sample-openvex.json canonical 3 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
9 mock sample-spdx.json sample-openvex.json shuffled 3 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
10 mock sample-spdx.json sample-openvex.json canonical 4 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
11 mock sample-spdx.json sample-openvex.json shuffled 4 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
12 mock sample-spdx.json sample-openvex.json canonical 5 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
13 mock sample-spdx.json sample-openvex.json shuffled 5 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
14 mock sample-spdx.json sample-openvex.json canonical 6 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
15 mock sample-spdx.json sample-openvex.json shuffled 6 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
16 mock sample-spdx.json sample-openvex.json canonical 7 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
17 mock sample-spdx.json sample-openvex.json shuffled 7 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
18 mock sample-spdx.json sample-openvex.json canonical 8 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
19 mock sample-spdx.json sample-openvex.json shuffled 8 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
20 mock sample-spdx.json sample-openvex.json canonical 9 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2
21 mock sample-spdx.json sample-openvex.json shuffled 9 d1cc5f0d22e863e457af589fb2c6c1737b67eb586338bccfe23ea7908c8a8b18 2

View File

@@ -0,0 +1,3 @@
{
"determinism_rate": 1.0
}

View 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()

View File

@@ -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()

View 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) |

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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
};
}

View File

@@ -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
}

View File

@@ -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
};
}
}

View File

@@ -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
}
}

View File

@@ -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";
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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}";
}
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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];
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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)
{

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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()
};
}
}
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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" />

View File

@@ -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; } = [];
}
}

View File

@@ -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;
}

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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