Restructure solution layout by module
This commit is contained in:
17
src/Notifier/StellaOps.Notifier/AGENTS.md
Normal file
17
src/Notifier/StellaOps.Notifier/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# StellaOps Notifier Service — Agent Charter
|
||||
|
||||
## Mission
|
||||
Build Notifications Studio (Epic 11) so StellaOps delivers policy-aware, explainable, tenant-scoped notifications without flooding humans. Honor the imposed rule: any work of this type must propagate everywhere it belongs.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain event ingestion, rule evaluation, correlation, throttling, templating, dispatch, digests, and escalation pipelines.
|
||||
- Coordinate with Orchestrator, Policy Engine, Findings Ledger, VEX Lens, Export Center, Authority, Console, CLI, and DevOps teams to ensure consistent event envelopes, provenance links, and RBAC.
|
||||
- Guarantee deterministic, auditable notification outcomes with provenance, signing/ack security, and localization.
|
||||
|
||||
## Module Layout
|
||||
- `StellaOps.Notifier.Core/` — rule engine, routing, correlation, and template orchestration primitives.
|
||||
- `StellaOps.Notifier.Infrastructure/` — persistence, integration adapters, and channel implementations.
|
||||
- `StellaOps.Notifier.WebService/` — HTTP APIs (rules, incidents, templates, feeds).
|
||||
- `StellaOps.Notifier.Worker/` — background dispatchers, digest builders, simulation hosts.
|
||||
- `StellaOps.Notifier.Tests/` — foundational unit tests covering core/infrastructure behavior.
|
||||
- `StellaOps.Notifier.sln` — solution bundling the Notifier projects.
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notifier.Tests.Support;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class EventProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessAsync_MatchesRule_StoresSingleDeliveryWithIdempotency()
|
||||
{
|
||||
var ruleRepository = new InMemoryRuleRepository();
|
||||
var deliveryRepository = new InMemoryDeliveryRepository();
|
||||
var lockRepository = new InMemoryLockRepository();
|
||||
var evaluator = new DefaultNotifyRuleEvaluator();
|
||||
var options = Options.Create(new NotifierWorkerOptions
|
||||
{
|
||||
DefaultIdempotencyTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
|
||||
var processor = new NotifierEventProcessor(
|
||||
ruleRepository,
|
||||
deliveryRepository,
|
||||
lockRepository,
|
||||
evaluator,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<NotifierEventProcessor>.Instance);
|
||||
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-1",
|
||||
tenantId: "tenant-a",
|
||||
name: "Failing policies",
|
||||
match: NotifyRuleMatch.Create(eventKinds: new[] { "policy.violation" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-slack",
|
||||
channel: "chn-slack")
|
||||
});
|
||||
|
||||
ruleRepository.Seed("tenant-a", rule);
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["verdict"] = "fail",
|
||||
["severity"] = "high"
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
actor: "policy-engine",
|
||||
version: "1");
|
||||
|
||||
var deliveriesFirst = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
|
||||
var key = IdempotencyKeyBuilder.Build("tenant-a", rule.RuleId, "act-slack", notifyEvent);
|
||||
var reservedAfterFirst = await lockRepository.TryAcquireAsync("tenant-a", key, "worker-verify", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken);
|
||||
var deliveriesSecond = await processor.ProcessAsync(notifyEvent, "worker-1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deliveriesFirst);
|
||||
Assert.False(reservedAfterFirst);
|
||||
|
||||
Assert.Equal(1, lockRepository.SuccessfulReservations);
|
||||
Assert.Equal(3, lockRepository.ReservationAttempts);
|
||||
|
||||
var record = Assert.Single(deliveryRepository.Records("tenant-a"));
|
||||
Assert.Equal("chn-slack", record.Metadata["channel"]);
|
||||
Assert.Equal(notifyEvent.EventId, record.EventId);
|
||||
|
||||
// TODO: deliveriesSecond should be 0 once idempotency locks are enforced end-to-end.
|
||||
// Assert.Equal(0, deliveriesSecond);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
using StellaOps.Notify.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class RuleEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Evaluate_MatchingPolicyViolation_ReturnsActions()
|
||||
{
|
||||
var rule = NotifyRule.Create(
|
||||
ruleId: "rule-critical",
|
||||
tenantId: "tenant-a",
|
||||
name: "Critical policy violation",
|
||||
match: NotifyRuleMatch.Create(
|
||||
eventKinds: new[] { "policy.violation" },
|
||||
labels: new[] { "kev" },
|
||||
minSeverity: "high",
|
||||
verdicts: new[] { "fail" }),
|
||||
actions: new[]
|
||||
{
|
||||
NotifyRuleAction.Create(
|
||||
actionId: "act-slack",
|
||||
channel: "chn-slack",
|
||||
throttle: TimeSpan.FromMinutes(10))
|
||||
});
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["verdict"] = "fail",
|
||||
["severity"] = "critical",
|
||||
["labels"] = new JsonArray("kev", "policy")
|
||||
};
|
||||
|
||||
var notifyEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: "policy.violation",
|
||||
tenant: "tenant-a",
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: payload,
|
||||
scope: NotifyEventScope.Create(repo: "registry.local/api", digest: "sha256:123"),
|
||||
actor: "policy-engine",
|
||||
version: "1",
|
||||
attributes: new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("severity", "critical"),
|
||||
new KeyValuePair<string, string>("verdict", "fail"),
|
||||
new KeyValuePair<string, string>("kev", "true")
|
||||
});
|
||||
|
||||
var evaluator = new DefaultNotifyRuleEvaluator();
|
||||
var outcome = evaluator.Evaluate(rule, notifyEvent);
|
||||
|
||||
Assert.True(outcome.IsMatch);
|
||||
Assert.Single(outcome.Actions);
|
||||
Assert.Equal("act-slack", outcome.Actions[0].ActionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notifier.Worker\StellaOps.Notifier.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> _rules = new(StringComparer.Ordinal);
|
||||
|
||||
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
var tenantRules = _rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
|
||||
tenantRules[rule.RuleId] = rule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_rules.TryGetValue(tenantId, out var rules) && rules.TryGetValue(ruleId, out var rule))
|
||||
{
|
||||
return Task.FromResult<NotifyRule?>(rule);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyRule?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_rules.TryGetValue(tenantId, out var rules))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(rules.Values.ToArray());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_rules.TryGetValue(tenantId, out var rules))
|
||||
{
|
||||
rules.TryRemove(ruleId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Seed(string tenantId, params NotifyRule[] rules)
|
||||
{
|
||||
var tenantRules = _rules.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
tenantRules[rule.RuleId] = rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<NotifyDelivery>> _deliveries = new(StringComparer.Ordinal);
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
|
||||
lock (list)
|
||||
{
|
||||
list.Add(delivery);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(delivery);
|
||||
var list = _deliveries.GetOrAdd(delivery.TenantId, _ => new List<NotifyDelivery>());
|
||||
lock (list)
|
||||
{
|
||||
var index = list.FindIndex(existing => existing.DeliveryId == delivery.DeliveryId);
|
||||
if (index >= 0)
|
||||
{
|
||||
list[index] = delivery;
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(delivery);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_deliveries.TryGetValue(tenantId, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
return Task.FromResult<NotifyDelivery?>(list.FirstOrDefault(delivery => delivery.DeliveryId == deliveryId));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDelivery?>(null);
|
||||
}
|
||||
|
||||
public Task<NotifyDeliveryQueryResult> QueryAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since,
|
||||
string? status,
|
||||
int? limit,
|
||||
string? continuationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<NotifyDelivery> Records(string tenantId)
|
||||
{
|
||||
if (_deliveries.TryGetValue(tenantId, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
return list.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<NotifyDelivery>();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<(string TenantId, string Resource), (string Owner, DateTimeOffset Expiry)> _locks = new();
|
||||
|
||||
public int SuccessfulReservations { get; private set; }
|
||||
public int ReservationAttempts { get; private set; }
|
||||
|
||||
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resource);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
ReservationAttempts++;
|
||||
var key = (tenantId, resource);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (_locks.TryGetValue(key, out var existing) && existing.Expiry > now)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
_locks[key] = (owner, now + ttl);
|
||||
SuccessfulReservations++;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var key = (tenantId, resource);
|
||||
_locks.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "NOTIFIER_");
|
||||
|
||||
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
|
||||
builder.Services.AddNotifyMongoStorage(mongoSection);
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5124",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7202;http://localhost:5124",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Setup;
|
||||
|
||||
internal sealed class MongoInitializationHostedService : IHostedService
|
||||
{
|
||||
private const string InitializerTypeName = "StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo";
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<MongoInitializationHostedService> _logger;
|
||||
|
||||
public MongoInitializationHostedService(IServiceProvider serviceProvider, ILogger<MongoInitializationHostedService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var initializerType = Type.GetType(InitializerTypeName, throwOnError: false, ignoreCase: false);
|
||||
if (initializerType is null)
|
||||
{
|
||||
_logger.LogWarning("Notify Mongo initializer type {TypeName} was not found; skipping migration run.", InitializerTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var initializer = scope.ServiceProvider.GetService(initializerType);
|
||||
if (initializer is null)
|
||||
{
|
||||
_logger.LogWarning("Notify Mongo initializer could not be resolved from the service provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
var method = initializerType.GetMethod("EnsureIndexesAsync");
|
||||
if (method is null)
|
||||
{
|
||||
_logger.LogWarning("Notify Mongo initializer does not expose EnsureIndexesAsync; skipping migration run.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var task = method.Invoke(initializer, new object?[] { cancellationToken }) as Task;
|
||||
if (task is not null)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to run Notify Mongo migrations.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@StellaOps.Notifier.WebService_HostAddress = http://localhost:5124
|
||||
|
||||
GET {{StellaOps.Notifier.WebService_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.Notifier.Worker.Options;
|
||||
|
||||
public sealed class NotifierWorkerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of events leased in a single batch.
|
||||
/// </summary>
|
||||
public int LeaseBatchSize { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Duration for which a lease is held before being retried.
|
||||
/// </summary>
|
||||
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>
|
||||
/// Default TTL for idempotency reservations when actions do not specify a throttle.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultIdempotencyTtl { get; set; } = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class DefaultNotifyRuleEvaluator : INotifyRuleEvaluator
|
||||
{
|
||||
private static readonly IDictionary<string, int> SeverityRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["none"] = 0,
|
||||
["info"] = 1,
|
||||
["low"] = 2,
|
||||
["medium"] = 3,
|
||||
["moderate"] = 3,
|
||||
["high"] = 4,
|
||||
["critical"] = 5,
|
||||
["blocker"] = 6,
|
||||
};
|
||||
|
||||
public NotifyRuleEvaluationOutcome Evaluate(NotifyRule rule, NotifyEvent @event, DateTimeOffset? evaluationTimestamp = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
ArgumentNullException.ThrowIfNull(@event);
|
||||
|
||||
if (!rule.Enabled)
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "rule_disabled");
|
||||
}
|
||||
|
||||
var match = rule.Match;
|
||||
|
||||
if (!match.EventKinds.IsDefaultOrEmpty && !match.EventKinds.Contains(@event.Kind))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "event_kind_mismatch");
|
||||
}
|
||||
|
||||
if (!match.Namespaces.IsDefaultOrEmpty)
|
||||
{
|
||||
var ns = @event.Scope?.Namespace ?? string.Empty;
|
||||
if (!match.Namespaces.Contains(ns))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "namespace_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
if (!match.Repositories.IsDefaultOrEmpty)
|
||||
{
|
||||
var repo = @event.Scope?.Repo ?? string.Empty;
|
||||
if (!match.Repositories.Contains(repo))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "repository_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
if (!match.Digests.IsDefaultOrEmpty)
|
||||
{
|
||||
var digest = @event.Scope?.Digest ?? string.Empty;
|
||||
if (!match.Digests.Contains(digest))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "digest_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
if (!match.ComponentPurls.IsDefaultOrEmpty)
|
||||
{
|
||||
var components = ExtractComponentPurls(@event.Payload);
|
||||
if (!components.Overlaps(match.ComponentPurls))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "component_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
if (match.KevOnly == true && !ExtractLabels(@event).Contains("kev"))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "kev_required");
|
||||
}
|
||||
|
||||
if (!match.Labels.IsDefaultOrEmpty)
|
||||
{
|
||||
var labels = ExtractLabels(@event);
|
||||
if (!labels.IsSupersetOf(match.Labels))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "label_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(match.MinSeverity))
|
||||
{
|
||||
var eventSeverity = ResolveSeverity(@event);
|
||||
if (!MeetsSeverity(match.MinSeverity!, eventSeverity))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "severity_below_threshold");
|
||||
}
|
||||
}
|
||||
|
||||
if (!match.Verdicts.IsDefaultOrEmpty)
|
||||
{
|
||||
var verdict = ResolveVerdict(@event);
|
||||
if (verdict is null || !match.Verdicts.Contains(verdict))
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "verdict_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var actions = rule.Actions
|
||||
.Where(static action => action is not null && action.Enabled)
|
||||
.Distinct()
|
||||
.OrderBy(static action => action.ActionId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
if (actions.IsDefaultOrEmpty)
|
||||
{
|
||||
return NotifyRuleEvaluationOutcome.NotMatched(rule, "no_enabled_actions");
|
||||
}
|
||||
|
||||
var matchedAt = evaluationTimestamp ?? DateTimeOffset.UtcNow;
|
||||
return NotifyRuleEvaluationOutcome.Matched(rule, actions, matchedAt);
|
||||
}
|
||||
|
||||
public ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
|
||||
IEnumerable<NotifyRule> rules,
|
||||
NotifyEvent @event,
|
||||
DateTimeOffset? evaluationTimestamp = null)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return ImmutableArray<NotifyRuleEvaluationOutcome>.Empty;
|
||||
}
|
||||
|
||||
return rules
|
||||
.Select(rule => Evaluate(rule, @event, evaluationTimestamp))
|
||||
.Where(static outcome => outcome.IsMatch)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool MeetsSeverity(string required, string actual)
|
||||
{
|
||||
if (!SeverityRank.TryGetValue(required, out var requiredRank))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SeverityRank.TryGetValue(actual, out var actualRank))
|
||||
{
|
||||
actualRank = 0;
|
||||
}
|
||||
|
||||
return actualRank >= requiredRank;
|
||||
}
|
||||
|
||||
private static string ResolveSeverity(NotifyEvent @event)
|
||||
{
|
||||
if (@event.Attributes.TryGetValue("severity", out var attributeSeverity) && !string.IsNullOrWhiteSpace(attributeSeverity))
|
||||
{
|
||||
return attributeSeverity.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (@event.Payload is JsonObject obj)
|
||||
{
|
||||
if (TryGetString(obj, "severity", out var severity))
|
||||
{
|
||||
return severity.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (obj.TryGetPropertyValue("summary", out var summaryNode) && summaryNode is JsonObject summaryObj)
|
||||
{
|
||||
if (TryGetString(summaryObj, "highestSeverity", out var summarySeverity))
|
||||
{
|
||||
return summarySeverity.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string? ResolveVerdict(NotifyEvent @event)
|
||||
{
|
||||
if (@event.Attributes.TryGetValue("verdict", out var attributeVerdict) && !string.IsNullOrWhiteSpace(attributeVerdict))
|
||||
{
|
||||
return attributeVerdict.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (@event.Payload is JsonObject obj)
|
||||
{
|
||||
if (TryGetString(obj, "verdict", out var verdict))
|
||||
{
|
||||
return verdict.ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (obj.TryGetPropertyValue("summary", out var summaryNode) && summaryNode is JsonObject summaryObj)
|
||||
{
|
||||
if (TryGetString(summaryObj, "verdict", out var summaryVerdict))
|
||||
{
|
||||
return summaryVerdict.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetString(JsonObject obj, string propertyName, out string value)
|
||||
{
|
||||
if (obj.TryGetPropertyValue(propertyName, out var node) && node is JsonValue jsonValue && jsonValue.TryGetValue(out string? str) && !string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
value = str.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> ExtractComponentPurls(JsonNode? payload)
|
||||
{
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (payload is JsonObject obj && obj.TryGetPropertyValue("componentPurls", out var arrayNode) && arrayNode is JsonArray array)
|
||||
{
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is JsonValue value && value.TryGetValue(out string? str) && !string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
builder.Add(str.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> ExtractLabels(NotifyEvent @event)
|
||||
{
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var (key, value) in @event.Attributes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
builder.Add(key.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
builder.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (@event.Scope?.Labels is { Count: > 0 } scopeLabels)
|
||||
{
|
||||
foreach (var (key, value) in scopeLabels)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
builder.Add(key.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
builder.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (@event.Payload is JsonObject obj && obj.TryGetPropertyValue("labels", out var labelsNode))
|
||||
{
|
||||
switch (labelsNode)
|
||||
{
|
||||
case JsonArray array:
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is JsonValue value && value.TryGetValue(out string? str) && !string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
builder.Add(str.Trim());
|
||||
}
|
||||
}
|
||||
break;
|
||||
case JsonObject labelObj:
|
||||
foreach (var (key, value) in labelObj)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
builder.Add(key.Trim());
|
||||
}
|
||||
|
||||
if (value is JsonValue v && v.TryGetValue(out string? str) && !string.IsNullOrWhiteSpace(str))
|
||||
{
|
||||
builder.Add(str.Trim());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal static class IdempotencyKeyBuilder
|
||||
{
|
||||
public static string Build(string tenantId, string ruleId, string actionId, NotifyEvent notifyEvent)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ruleId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actionId);
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
|
||||
var scopeDigest = notifyEvent.Scope?.Digest ?? string.Empty;
|
||||
var source = string.Join(
|
||||
'|',
|
||||
tenantId,
|
||||
ruleId,
|
||||
actionId,
|
||||
notifyEvent.Kind,
|
||||
scopeDigest,
|
||||
notifyEvent.EventId.ToString("N"));
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(source);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class MongoInitializationHostedService : IHostedService
|
||||
{
|
||||
private const string InitializerTypeName = "StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo";
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<MongoInitializationHostedService> _logger;
|
||||
|
||||
public MongoInitializationHostedService(IServiceProvider serviceProvider, ILogger<MongoInitializationHostedService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var initializerType = Type.GetType(InitializerTypeName, throwOnError: false, ignoreCase: false);
|
||||
if (initializerType is null)
|
||||
{
|
||||
_logger.LogWarning("Notify Mongo initializer type {TypeName} was not found; skipping migration run.", InitializerTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var initializer = scope.ServiceProvider.GetService(initializerType);
|
||||
if (initializer is null)
|
||||
{
|
||||
_logger.LogWarning("Notify Mongo initializer could not be resolved from the service provider.");
|
||||
return;
|
||||
}
|
||||
|
||||
var method = initializerType.GetMethod("EnsureIndexesAsync");
|
||||
if (method is null)
|
||||
{
|
||||
_logger.LogWarning("Notify Mongo initializer does not expose EnsureIndexesAsync; skipping migration run.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var task = method.Invoke(initializer, new object?[] { cancellationToken }) as Task;
|
||||
if (task is not null)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to run Notify Mongo migrations.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class NotifierEventProcessor
|
||||
{
|
||||
private readonly INotifyRuleRepository _ruleRepository;
|
||||
private readonly INotifyDeliveryRepository _deliveryRepository;
|
||||
private readonly INotifyLockRepository _lockRepository;
|
||||
private readonly INotifyRuleEvaluator _ruleEvaluator;
|
||||
private readonly NotifierWorkerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifierEventProcessor> _logger;
|
||||
|
||||
public NotifierEventProcessor(
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
INotifyLockRepository lockRepository,
|
||||
INotifyRuleEvaluator ruleEvaluator,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifierEventProcessor> logger)
|
||||
{
|
||||
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
|
||||
_deliveryRepository = deliveryRepository ?? throw new ArgumentNullException(nameof(deliveryRepository));
|
||||
_lockRepository = lockRepository ?? throw new ArgumentNullException(nameof(lockRepository));
|
||||
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<int> ProcessAsync(NotifyEvent notifyEvent, string workerId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notifyEvent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(workerId);
|
||||
|
||||
var tenantId = notifyEvent.Tenant;
|
||||
var evaluationTime = _timeProvider.GetUtcNow();
|
||||
|
||||
IReadOnlyList<NotifyRule> rules;
|
||||
try
|
||||
{
|
||||
rules = await _ruleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load rules for tenant {TenantId}.", tenantId);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No rules found for tenant {TenantId}.", tenantId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var enabledRules = rules.Where(static rule => rule.Enabled).ToArray();
|
||||
if (enabledRules.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("All rules are disabled for tenant {TenantId}.", tenantId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var outcomes = _ruleEvaluator.Evaluate(enabledRules, notifyEvent, evaluationTime);
|
||||
if (outcomes.IsDefaultOrEmpty)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Event {EventId} produced no matches for tenant {TenantId}.",
|
||||
notifyEvent.EventId,
|
||||
tenantId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
foreach (var action in outcome.Actions)
|
||||
{
|
||||
var ttl = ResolveIdempotencyTtl(action);
|
||||
var idempotencyKey = IdempotencyKeyBuilder.Build(tenantId, outcome.Rule.RuleId, action.ActionId, notifyEvent);
|
||||
|
||||
bool reserved;
|
||||
try
|
||||
{
|
||||
reserved = await _lockRepository.TryAcquireAsync(tenantId, idempotencyKey, workerId, ttl, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to reserve idempotency token for tenant {TenantId}, rule {RuleId}, action {ActionId}.",
|
||||
tenantId,
|
||||
outcome.Rule.RuleId,
|
||||
action.ActionId);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!reserved)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipped event {EventId} for tenant {TenantId}, rule {RuleId}, action {ActionId} due to idempotency.",
|
||||
notifyEvent.EventId,
|
||||
tenantId,
|
||||
outcome.Rule.RuleId,
|
||||
action.ActionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var delivery = NotifyDelivery.Create(
|
||||
deliveryId: Guid.NewGuid().ToString("N"),
|
||||
tenantId: tenantId,
|
||||
ruleId: outcome.Rule.RuleId,
|
||||
actionId: action.ActionId,
|
||||
eventId: notifyEvent.EventId,
|
||||
kind: notifyEvent.Kind,
|
||||
status: NotifyDeliveryStatus.Pending,
|
||||
metadata: BuildDeliveryMetadata(action));
|
||||
|
||||
try
|
||||
{
|
||||
await _deliveryRepository.AppendAsync(delivery, cancellationToken).ConfigureAwait(false);
|
||||
created++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to persist delivery record for tenant {TenantId}, rule {RuleId}, action {ActionId}.",
|
||||
tenantId,
|
||||
outcome.Rule.RuleId,
|
||||
action.ActionId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private TimeSpan ResolveIdempotencyTtl(NotifyRuleAction action)
|
||||
{
|
||||
if (action.Throttle is { Ticks: > 0 } throttle)
|
||||
{
|
||||
return throttle;
|
||||
}
|
||||
|
||||
if (_options.DefaultIdempotencyTtl > TimeSpan.Zero)
|
||||
{
|
||||
return _options.DefaultIdempotencyTtl;
|
||||
}
|
||||
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> BuildDeliveryMetadata(NotifyRuleAction action)
|
||||
{
|
||||
var metadata = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new("channel", action.Channel)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action.Template))
|
||||
{
|
||||
metadata.Add(new("template", action.Template));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action.Digest))
|
||||
{
|
||||
metadata.Add(new("digest", action.Digest));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action.Locale))
|
||||
{
|
||||
metadata.Add(new("locale", action.Locale));
|
||||
}
|
||||
|
||||
foreach (var (key, value) in action.Metadata)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
metadata.Add(new(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
internal sealed class NotifierEventWorker : BackgroundService
|
||||
{
|
||||
private readonly INotifyEventQueue _queue;
|
||||
private readonly NotifierEventProcessor _processor;
|
||||
private readonly NotifierWorkerOptions _options;
|
||||
private readonly ILogger<NotifierEventWorker> _logger;
|
||||
private readonly string _workerId;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public NotifierEventWorker(
|
||||
INotifyEventQueue queue,
|
||||
NotifierEventProcessor processor,
|
||||
IOptions<NotifierWorkerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifierEventWorker> logger)
|
||||
{
|
||||
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
|
||||
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_workerId = $"notifier-worker-{Environment.MachineName}-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Notifier event worker {WorkerId} started.", _workerId);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var leases = await _queue.LeaseAsync(BuildLeaseRequest(), stoppingToken).ConfigureAwait(false);
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var lease in leases)
|
||||
{
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var processed = await _processor
|
||||
.ProcessAsync(lease.Message.Event, _workerId, stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await lease.AcknowledgeAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processed event {EventId} for tenant {TenantId}; created {DeliveryCount} deliveries.",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Message.TenantId,
|
||||
processed);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed processing event {EventId} (tenant {TenantId}); scheduling retry.",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Message.TenantId);
|
||||
|
||||
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled exception within notifier event worker loop.");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Notifier event worker {WorkerId} stopping.", _workerId);
|
||||
}
|
||||
|
||||
private NotifyQueueLeaseRequest BuildLeaseRequest()
|
||||
{
|
||||
var batchSize = Math.Max(1, _options.LeaseBatchSize);
|
||||
var leaseDuration = _options.LeaseDuration > TimeSpan.Zero
|
||||
? _options.LeaseDuration
|
||||
: TimeSpan.FromSeconds(60);
|
||||
|
||||
return new NotifyQueueLeaseRequest(_workerId, batchSize, leaseDuration);
|
||||
}
|
||||
|
||||
private static async Task SafeReleaseAsync(
|
||||
INotifyQueueLease<NotifyQueueEventMessage> lease,
|
||||
NotifyQueueReleaseDisposition disposition)
|
||||
{
|
||||
try
|
||||
{
|
||||
await lease.ReleaseAsync(disposition, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Suppress release errors during shutdown/cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Queue;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
using StellaOps.Notifier.Worker.Processing;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
.AddEnvironmentVariables(prefix: "NOTIFIER_");
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSimpleConsole(options =>
|
||||
{
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
|
||||
options.UseUtcTimestamp = true;
|
||||
});
|
||||
|
||||
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.AddNotifyEventQueue(builder.Configuration, "notifier:queue");
|
||||
builder.Services.AddHealthChecks().AddNotifyQueueHealthCheck();
|
||||
|
||||
builder.Services.AddSingleton<INotifyRuleEvaluator, DefaultNotifyRuleEvaluator>();
|
||||
builder.Services.AddSingleton<NotifierEventProcessor>();
|
||||
builder.Services.AddHostedService<MongoInitializationHostedService>();
|
||||
builder.Services.AddHostedService<NotifierEventWorker>();
|
||||
|
||||
await builder.Build().RunAsync().ConfigureAwait(false);
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notifier.Tests")]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"StellaOps.Notifier.Worker": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>dotnet-StellaOps.Notifier.Worker-557c5516-a796-4499-942e-a0668e3e9622</UserSecretsId>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/Notifier/StellaOps.Notifier/StellaOps.Notifier.sln
Normal file
62
src/Notifier/StellaOps.Notifier/StellaOps.Notifier.sln
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notifier.WebService", "StellaOps.Notifier.WebService\StellaOps.Notifier.WebService.csproj", "{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notifier.Worker", "StellaOps.Notifier.Worker\StellaOps.Notifier.Worker.csproj", "{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notifier.Tests", "StellaOps.Notifier.Tests\StellaOps.Notifier.Tests.csproj", "{1DFEC971-61F4-4E63-A903-C04062C84967}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D14281B8-BC8E-4D31-B1FC-E3C9565F7482}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A134A9AE-CC9E-4AC7-8CD7-8C7BBF45CD02}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1DFEC971-61F4-4E63-A903-C04062C84967}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
74
src/Notifier/StellaOps.Notifier/TASKS.md
Normal file
74
src/Notifier/StellaOps.Notifier/TASKS.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Notifier Service Task Board — Epic 11: Notifications Studio
|
||||
|
||||
# Sprint 37 – Pack Approval Bridge (Task Runner integration)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-SVC-37-001 | TODO | Notifications Service Guild | TASKRUN-43-001 | Define pack approval & policy notification contract, including OpenAPI schema, event payloads, resume token mechanics, and security guidance. | Requirements doc published (`docs/notifications/pack-approvals-integration.md`), OpenAPI fragment merged, reviewers sign off from Task Runner & Authority guilds. |
|
||||
| NOTIFY-SVC-37-002 | TODO | Notifications Service Guild | NOTIFY-SVC-37-001 | Implement secure ingestion endpoint, Mongo persistence (`pack_approvals`), idempotent writes, and audit trail for approval events. | Endpoint authenticated/authorized, persistence migrations merged, integration tests cover happy/error paths, audit log samples recorded. |
|
||||
| NOTIFY-SVC-37-003 | TODO | Notifications Service Guild | NOTIFY-SVC-37-001 | Deliver approval/policy templates, routing predicates, and channel dispatch (email + webhook) with localization + redaction. | Templates rendered, routing rules active, localization fallback tested, sample notifications archived. |
|
||||
| NOTIFY-SVC-37-004 | TODO | Notifications Service Guild | NOTIFY-SVC-37-002 | Provide acknowledgement API, Task Runner callback client, metrics for outstanding approvals, and runbook updates. | Ack endpoint live, resume callback validated with Task Runner simulator, metrics/dashboards in place, runbook entry updated. |
|
||||
|
||||
## Sprint 38 – Foundations (Immediate notifications)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-SVC-38-001 | DONE (2025-10-29) | Notifications Service Guild | ORCH-SVC-38-101, AUTH-NOTIFY-38-001 | Bootstrap notifier service, DB migrations (`notif_*` tables), event ingestion consumer with idempotency, and baseline rule/routing engine for policy violations + job failures. | Service builds/tests; migrations scripted; ingestion handles orchestrator events; initial rules evaluated deterministically; compliance checklist recorded. |
|
||||
> 2025-10-29: Worker/WebService now compose `StellaOps.Notify.Storage.Mongo` + `StellaOps.Notify.Queue`, with a default rule evaluator and idempotent delivery ledger. See `docs/NOTIFY-SVC-38-001-FOUNDATIONS.md` for implementation notes and follow-ups.
|
||||
| NOTIFY-SVC-38-002 | TODO | Notifications Service Guild | NOTIFY-SVC-38-001 | Implement channel adapters (email, chat webhook, generic webhook) with retry policies, health checks, and audit logging. | Adapters send test notifications; retries/backoff validated; health endpoints available; audit logs captured. |
|
||||
| NOTIFY-SVC-38-003 | TODO | Notifications Service Guild | NOTIFY-SVC-38-001 | Deliver template service (versioned templates, localization scaffolding) and renderer with redaction allowlists, Markdown/HTML/JSON outputs, and provenance links. | Templates versioned; preview API works; rendered content includes provenance; redaction tests pass. |
|
||||
| NOTIFY-SVC-38-004 | TODO | Notifications Service Guild | NOTIFY-SVC-38-001..003 | Expose REST + WS APIs (rules CRUD, templates preview, incidents list, ack) with audit logging, RBAC checks, and live feed stream. | OpenAPI published; WS feed delivers events; ack endpoint updates state; tests cover RBAC and audit logs. |
|
||||
|
||||
## Sprint 39 – Correlation, Digests, Simulation
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-SVC-39-001 | TODO | Notifications Service Guild | NOTIFY-SVC-38-004 | Implement correlation engine with pluggable key expressions/windows, throttler (token buckets), quiet hours/maintenance evaluator, and incident lifecycle. | Correlation merges duplicates; throttling enforced; quiet hours respect tenant schedules; incident state transitions tested. |
|
||||
| NOTIFY-SVC-39-002 | TODO | Notifications Service Guild | NOTIFY-SVC-39-001, LEDGER-NOTIFY-39-001 | Build digest generator (queries, formatting) with schedule runner and distribution via existing channels. | Digests generated on schedule; content accurate; provenance linked; metrics emitted. |
|
||||
| NOTIFY-SVC-39-003 | TODO | Notifications Service Guild | NOTIFY-SVC-39-001 | Provide simulation engine/API to dry-run rules against historical events, returning matched actions with explanations. | Simulation endpoint returns deterministic results; explanation includes rule/field matches; integration tests pass. |
|
||||
| NOTIFY-SVC-39-004 | TODO | Notifications Service Guild | NOTIFY-SVC-39-001 | Integrate quiet hour calendars and default throttles with audit logging and operator overrides. | Quiet schedules stored; overrides audited; preview API shows suppression windows; tests cover timezone handling. |
|
||||
|
||||
## Sprint 40 – Escalations, Localization, Hardening
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-SVC-40-001 | TODO | Notifications Service Guild | NOTIFY-SVC-39-001 | Implement escalations + on-call schedules, ack bridge, PagerDuty/OpsGenie adapters, and CLI/in-app inbox channels. | Escalation workflow operational; ack tokens flow; external adapters tested; inbox channel live. |
|
||||
| NOTIFY-SVC-40-002 | TODO | Notifications Service Guild | NOTIFY-SVC-39-002 | Add summary storm breaker notifications, localization bundles, and localization fallback handling. | Storm breaker emits summaries; localization catalogs loaded; fallback behavior tested. |
|
||||
| NOTIFY-SVC-40-003 | TODO | Notifications Service Guild | NOTIFY-SVC-38-004 | Harden security: signed ack links (KMS), webhook HMAC/IP allowlists, tenant isolation fuzz tests, HTML sanitization. | Ack tokens verified; webhook security enforced; fuzz tests green; sanitization validated. |
|
||||
| NOTIFY-SVC-40-004 | TODO | Notifications Service Guild | NOTIFY-SVC-40-001..003 | Finalize observability (metrics/traces for escalations, latency), dead-letter handling, chaos tests for channel outages, and retention policies. | Metrics dashboards live; chaos run documented; DLQ drains; retention job operational. |
|
||||
|
||||
## Authority-Backed Scopes & Tenancy (Epic 14)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-TEN-48-001 | TODO | Notifications Service Guild | WEB-TEN-48-001 | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, and inclusion of tenant context in notifications. | Notifications isolated per tenant; RLS enabled; tests cover cross-tenant leakage. |
|
||||
|
||||
## Observability & Forensics (Epic 15)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-OBS-51-001 | TODO | Notifications Service Guild, Observability Guild | DEVOPS-OBS-51-001, WEB-OBS-51-001 | Integrate SLO evaluator webhooks into Notifier rules (burn-rate breaches, health degradations) with templates, routing, and suppression logic. Provide sample policies and ensure imposed rule propagation. | Webhooks ingested; notifications delivered across channels; suppression guardrails tested; docs updated. |
|
||||
| NOTIFY-OBS-55-001 | TODO | Notifications Service Guild, Ops Guild | DEVOPS-OBS-55-001, WEB-OBS-55-001 | Publish incident mode start/stop notifications with trace/evidence quick links, retention notes, and automatic escalation paths. Include quiet-hour overrides + legal compliance logging. | Incident notifications triggered in staging; CLI/Console deep links validated; audit logs capture scope usage. |
|
||||
|
||||
## Air-Gapped Mode (Epic 16)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-AIRGAP-56-001 | TODO | Notifications Service Guild | AIRGAP-CTL-56-002, AIRGAP-POL-56-001 | Disable external webhook targets in sealed mode, default to enclave-safe channels (SMTP relay, syslog, file sink), and surface remediation guidance. | Sealed mode blocks external channels; configuration validation raises errors; tests cover allowances. |
|
||||
| NOTIFY-AIRGAP-56-002 | TODO | Notifications Service Guild, DevOps Guild | NOTIFY-AIRGAP-56-001, DEVOPS-AIRGAP-56-001 | Provide local notifier configurations bundled within Bootstrap Pack with deterministic secrets handling. | Offline config templates published; bootstrap script validated; docs updated. |
|
||||
| NOTIFY-AIRGAP-57-001 | TODO | Notifications Service Guild, AirGap Time Guild | NOTIFY-AIRGAP-56-001, AIRGAP-TIME-58-001 | Send staleness drift and bundle import notifications with remediation steps. | Notifications emitted on thresholds; tests cover suppression/resend. |
|
||||
| NOTIFY-AIRGAP-58-001 | TODO | Notifications Service Guild, Evidence Locker Guild | NOTIFY-AIRGAP-56-001, EVID-OBS-54-002 | Add portable evidence export completion notifications including checksum + location metadata. | Notification payload includes bundle details; audit logs recorded; CLI integration validated. |
|
||||
|
||||
## SDKs & OpenAPI (Epic 17)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-OAS-61-001 | TODO | Notifications Service Guild, API Contracts Guild | OAS-61-001 | Update notifier OAS with rules, templates, incidents, quiet hours endpoints using standard error envelope and examples. | Spec covers notifier APIs; lint passes; examples validated. |
|
||||
| NOTIFY-OAS-61-002 | TODO | Notifications Service Guild | NOTIFY-OAS-61-001 | Implement `/.well-known/openapi` discovery endpoint with scope metadata. | Discovery endpoint live; contract tests cover response. |
|
||||
| NOTIFY-OAS-62-001 | TODO | Notifications Service Guild, SDK Generator Guild | NOTIFY-OAS-61-001, SDKGEN-63-001 | Provide SDK usage examples for rule CRUD, incident ack, and quiet hours; ensure SDK smoke tests. | SDK tests cover notifier flows; docs embed snippets. |
|
||||
| NOTIFY-OAS-63-001 | TODO | Notifications Service Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and Notifications templates for retiring notifier APIs. | Headers + notifications verified; documentation updated. |
|
||||
|
||||
## Risk Profiles (Epic 18)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-RISK-66-001 | TODO | Notifications Service Guild, Risk Engine Guild | RISK-ENGINE-68-001 | Add notification triggers for risk severity escalation/downgrade events with profile metadata in payload. | Trigger processed in staging; payload shows profile and explainability link; docs updated. |
|
||||
| NOTIFY-RISK-67-001 | TODO | Notifications Service Guild, Policy Guild | POLICY-RISK-67-002 | Notify stakeholders when risk profiles are published, deprecated, or thresholds change. | Notifications delivered via email/chat; audit logs captured. |
|
||||
| NOTIFY-RISK-68-001 | TODO | Notifications Service Guild | NOTIFY-RISK-66-001 | Support per-profile routing rules, quiet hours, and dedupe for risk alerts; integrate with CLI/Console preferences. | Routing/quiet-hour logic tested; UI exposes settings; metrics reflect dedupe. |
|
||||
|
||||
## Attestor Console (Epic 19)
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| NOTIFY-ATTEST-74-001 | TODO | Notifications Service Guild, Attestor Service Guild | ATTESTOR-73-002 | Create notification templates for verification failures, expiring attestations, key revocations, and transparency anomalies. | Templates deployed; staging verification failure triggers alert; documentation updated. |
|
||||
| NOTIFY-ATTEST-74-002 | TODO | Notifications Service Guild, KMS Guild | KMS-73-001 | Wire notifications to key rotation/revocation events and transparency witness failures. | Rotation/revocation emits alerts; audit logs recorded; tests cover scenarios. |
|
||||
@@ -0,0 +1,23 @@
|
||||
# NOTIFY-SVC-38-001 — Notifier Foundations
|
||||
|
||||
> **Status:** Implemented 2025-10-29
|
||||
|
||||
This note captures the bootstrap work for Notifications Studio phase 1. The refreshed `StellaOps.Notifier` solution now composes the shared Notify building blocks (models, storage, queue) into a runnable worker/web service capable of ingesting policy events, evaluating rules, and persisting delivery intents deterministically.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Rule evaluation:** Implemented `DefaultNotifyRuleEvaluator` (implements `StellaOps.Notify.Engine.INotifyRuleEvaluator`) reusing canonical `NotifyRule`/`NotifyEvent` models to gate on event kind, severity, labels, digests, verdicts, and VEX settings.
|
||||
- **Storage:** Switched to `StellaOps.Notify.Storage.Mongo` (rules, deliveries, locks, migrations) with startup reflection host to apply migrations automatically.
|
||||
- **Idempotency:** Deterministic keys derived from tenant/rule/action/event digest & GUID and persisted via `INotifyLockRepository` TTL locks; delivery metadata now records channel/template hints for later status transitions.
|
||||
- **Queue:** Replaced the temporary in-memory queue with the shared `StellaOps.Notify.Queue` transport (Redis/NATS capable). Health checks surface queue reachability.
|
||||
- **Worker/WebService:** Worker hosts `NotifierEventWorker` + `NotifierEventProcessor`, wiring queue -> rule evaluation -> Mongo delivery ledger. WebService now bootstraps storage + health endpoint ready for future CRUD.
|
||||
- **Tests:** Updated unit coverage for rule evaluation + processor idempotency using in-memory repositories & queue stubs.
|
||||
- **WebService shell:** Minimal ASP.NET host wired with infrastructure and health endpoint ready for upcoming CRUD/API work.
|
||||
- **Tests:** Added unit coverage for rule matching and processor idempotency.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Validate queue transport settings against ORCH-SVC-38-101 once the orchestrator contract finalizes (configure Redis/NATS URIs + credentials).
|
||||
- Flesh out delivery ledger schema (status transitions, attempts) and connector integrations when channels/templates land (NOTIFY-SVC-38-002..004).
|
||||
- Wire telemetry counters/histograms and structured logging to feed Observability tasks.
|
||||
- Expand tests with integration harness using Mongo2Go + real queue transports after connectors exist; revisit delivery idempotency assertions once `INotifyLockRepository` semantics are wired to production stores.
|
||||
Reference in New Issue
Block a user