up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,17 +1,17 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Engine;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Response payload describing channel health diagnostics.
/// </summary>
public sealed record ChannelHealthResponse(
string TenantId,
string ChannelId,
ChannelHealthStatus Status,
string? Message,
DateTimeOffset CheckedAt,
string TraceId,
IReadOnlyDictionary<string, string> Metadata);
using System;
using System.Collections.Generic;
using StellaOps.Notify.Engine;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Response payload describing channel health diagnostics.
/// </summary>
public sealed record ChannelHealthResponse(
string TenantId,
string ChannelId,
ChannelHealthStatus Status,
string? Message,
DateTimeOffset CheckedAt,
string TraceId,
IReadOnlyDictionary<string, string> Metadata);

View File

@@ -1,56 +1,56 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Payload for Notify channel test send requests.
/// </summary>
public sealed record ChannelTestSendRequest
{
/// <summary>
/// Optional override for the default channel destination (email address, webhook URL, Slack channel, etc.).
/// </summary>
public string? Target { get; init; }
/// <summary>
/// Optional template identifier to drive future rendering hooks.
/// </summary>
public string? TemplateId { get; init; }
/// <summary>
/// Preview title (fallback supplied when omitted).
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Optional short summary to show in UI cards.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Primary body payload rendered for the connector.
/// </summary>
public string? Body { get; init; }
/// <summary>
/// Optional text-only representation (used by email/plaintext destinations).
/// </summary>
public string? TextBody { get; init; }
/// <summary>
/// Optional locale hint (RFC 5646).
/// </summary>
public string? Locale { get; init; }
/// <summary>
/// Optional metadata for future expansion (headers, context labels, etc.).
/// </summary>
public IDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Optional attachment references emitted with the preview.
/// </summary>
[JsonPropertyName("attachments")]
public IList<string>? AttachmentRefs { get; init; }
}
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Payload for Notify channel test send requests.
/// </summary>
public sealed record ChannelTestSendRequest
{
/// <summary>
/// Optional override for the default channel destination (email address, webhook URL, Slack channel, etc.).
/// </summary>
public string? Target { get; init; }
/// <summary>
/// Optional template identifier to drive future rendering hooks.
/// </summary>
public string? TemplateId { get; init; }
/// <summary>
/// Preview title (fallback supplied when omitted).
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Optional short summary to show in UI cards.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Primary body payload rendered for the connector.
/// </summary>
public string? Body { get; init; }
/// <summary>
/// Optional text-only representation (used by email/plaintext destinations).
/// </summary>
public string? TextBody { get; init; }
/// <summary>
/// Optional locale hint (RFC 5646).
/// </summary>
public string? Locale { get; init; }
/// <summary>
/// Optional metadata for future expansion (headers, context labels, etc.).
/// </summary>
public IDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Optional attachment references emitted with the preview.
/// </summary>
[JsonPropertyName("attachments")]
public IList<string>? AttachmentRefs { get; init; }
}

View File

@@ -1,16 +1,16 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Response payload summarising a Notify channel test send preview.
/// </summary>
public sealed record ChannelTestSendResponse(
string TenantId,
string ChannelId,
NotifyDeliveryRendered Preview,
DateTimeOffset QueuedAt,
string TraceId,
IReadOnlyDictionary<string, string> Metadata);
using System;
using System.Collections.Generic;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Contracts;
/// <summary>
/// Response payload summarising a Notify channel test send preview.
/// </summary>
public sealed record ChannelTestSendResponse(
string TenantId,
string ChannelId,
NotifyDeliveryRendered Preview,
DateTimeOffset QueuedAt,
string TraceId,
IReadOnlyDictionary<string, string> Metadata);

View File

@@ -1,5 +1,5 @@
namespace StellaOps.Notify.WebService.Contracts;
internal sealed record AcquireLockRequest(string Resource, string Owner, int TtlSeconds);
internal sealed record ReleaseLockRequest(string Resource, string Owner);
namespace StellaOps.Notify.WebService.Contracts;
internal sealed record AcquireLockRequest(string Resource, string Owner, int TtlSeconds);
internal sealed record ReleaseLockRequest(string Resource, string Owner);

View File

@@ -1,47 +1,47 @@
using System;
namespace StellaOps.Notify.WebService.Diagnostics;
/// <summary>
/// Tracks Notify WebService readiness information for `/readyz`.
/// </summary>
internal sealed class ServiceStatus
{
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startedAt;
private ReadySnapshot _readySnapshot;
public ServiceStatus(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_startedAt = _timeProvider.GetUtcNow();
_readySnapshot = ReadySnapshot.CreateInitial(_startedAt);
}
public ServiceSnapshot CreateSnapshot()
{
var now = _timeProvider.GetUtcNow();
return new ServiceSnapshot(_startedAt, now, _readySnapshot);
}
public void RecordReadyCheck(bool success, TimeSpan latency, string? errorMessage = null)
{
var timestamp = _timeProvider.GetUtcNow();
_readySnapshot = new ReadySnapshot(timestamp, latency, success, success ? null : errorMessage);
}
public readonly record struct ServiceSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
ReadySnapshot Ready);
public readonly record struct ReadySnapshot(
DateTimeOffset CheckedAt,
TimeSpan? Latency,
bool IsReady,
string? Error)
{
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
=> new(timestamp, null, false, "initialising");
}
}
using System;
namespace StellaOps.Notify.WebService.Diagnostics;
/// <summary>
/// Tracks Notify WebService readiness information for `/readyz`.
/// </summary>
internal sealed class ServiceStatus
{
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startedAt;
private ReadySnapshot _readySnapshot;
public ServiceStatus(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_startedAt = _timeProvider.GetUtcNow();
_readySnapshot = ReadySnapshot.CreateInitial(_startedAt);
}
public ServiceSnapshot CreateSnapshot()
{
var now = _timeProvider.GetUtcNow();
return new ServiceSnapshot(_startedAt, now, _readySnapshot);
}
public void RecordReadyCheck(bool success, TimeSpan latency, string? errorMessage = null)
{
var timestamp = _timeProvider.GetUtcNow();
_readySnapshot = new ReadySnapshot(timestamp, latency, success, success ? null : errorMessage);
}
public readonly record struct ServiceSnapshot(
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
ReadySnapshot Ready);
public readonly record struct ReadySnapshot(
DateTimeOffset CheckedAt,
TimeSpan? Latency,
bool IsReady,
string? Error)
{
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
=> new(timestamp, null, false, "initialising");
}
}

View File

@@ -1,37 +1,37 @@
using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Notify.WebService.Extensions;
internal static class ConfigurationExtensions
{
public static IConfigurationBuilder AddNotifyYaml(this IConfigurationBuilder builder, string path)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
return builder.AddJsonStream(stream);
}
}
using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Notify.WebService.Extensions;
internal static class ConfigurationExtensions
{
public static IConfigurationBuilder AddNotifyYaml(this IConfigurationBuilder builder, string path)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var payload = JsonSerializer.Serialize(yamlObject);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
return builder.AddJsonStream(stream);
}
}

View File

@@ -1,44 +1,44 @@
using System;
using System.IO;
using StellaOps.Notify.WebService.Options;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Notify.WebService.Hosting;
internal static class NotifyPluginHostFactory
{
public static PluginHostOptions Build(NotifyWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
var hostOptions = new PluginHostOptions
{
BaseDirectory = options.Plugins.BaseDirectory ?? Path.Combine(contentRootPath, ".."),
PluginsDirectory = options.Plugins.Directory ?? Path.Combine("plugins", "notify"),
PrimaryPrefix = "StellaOps.Notify"
};
if (!Path.IsPathRooted(hostOptions.BaseDirectory))
{
hostOptions.BaseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, hostOptions.BaseDirectory));
}
if (!Path.IsPathRooted(hostOptions.PluginsDirectory))
{
hostOptions.PluginsDirectory = Path.Combine(hostOptions.BaseDirectory, hostOptions.PluginsDirectory);
}
foreach (var pattern in options.Plugins.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
foreach (var prefix in options.Plugins.OrderedPlugins)
{
hostOptions.PluginOrder.Add(prefix);
}
return hostOptions;
}
}
using System;
using System.IO;
using StellaOps.Notify.WebService.Options;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Notify.WebService.Hosting;
internal static class NotifyPluginHostFactory
{
public static PluginHostOptions Build(NotifyWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
var hostOptions = new PluginHostOptions
{
BaseDirectory = options.Plugins.BaseDirectory ?? Path.Combine(contentRootPath, ".."),
PluginsDirectory = options.Plugins.Directory ?? Path.Combine("plugins", "notify"),
PrimaryPrefix = "StellaOps.Notify"
};
if (!Path.IsPathRooted(hostOptions.BaseDirectory))
{
hostOptions.BaseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, hostOptions.BaseDirectory));
}
if (!Path.IsPathRooted(hostOptions.PluginsDirectory))
{
hostOptions.PluginsDirectory = Path.Combine(hostOptions.BaseDirectory, hostOptions.PluginsDirectory);
}
foreach (var pattern in options.Plugins.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
foreach (var prefix in options.Plugins.OrderedPlugins)
{
hostOptions.PluginOrder.Add(prefix);
}
return hostOptions;
}
}

View File

@@ -1,29 +1,29 @@
using Microsoft.AspNetCore.Http;
namespace StellaOps.Notify.WebService.Internal;
internal sealed class JsonHttpResult : IResult
{
private readonly string _payload;
private readonly int _statusCode;
private readonly string? _location;
public JsonHttpResult(string payload, int statusCode, string? location)
{
_payload = payload;
_statusCode = statusCode;
_location = location;
}
public async Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = _statusCode;
httpContext.Response.ContentType = "application/json";
if (!string.IsNullOrWhiteSpace(_location))
{
httpContext.Response.Headers.Location = _location;
}
await httpContext.Response.WriteAsync(_payload);
}
}
using Microsoft.AspNetCore.Http;
namespace StellaOps.Notify.WebService.Internal;
internal sealed class JsonHttpResult : IResult
{
private readonly string _payload;
private readonly int _statusCode;
private readonly string? _location;
public JsonHttpResult(string payload, int statusCode, string? location)
{
_payload = payload;
_statusCode = statusCode;
_location = location;
}
public async Task ExecuteAsync(HttpContext httpContext)
{
httpContext.Response.StatusCode = _statusCode;
httpContext.Response.ContentType = "application/json";
if (!string.IsNullOrWhiteSpace(_location))
{
httpContext.Response.Headers.Location = _location;
}
await httpContext.Response.WriteAsync(_payload);
}
}

View File

@@ -1,60 +1,60 @@
using System.Collections.Generic;
namespace StellaOps.Notify.WebService.Options;
/// <summary>
/// Strongly typed configuration for the Notify WebService host.
/// </summary>
public sealed class NotifyWebServiceOptions
{
public const string SectionName = "notify";
/// <summary>
/// Schema version that downstream consumers can use to detect breaking changes.
/// </summary>
public int SchemaVersion { get; set; } = 1;
/// <summary>
/// Authority / authentication configuration.
/// </summary>
public AuthorityOptions Authority { get; set; } = new();
/// <summary>
using System.Collections.Generic;
namespace StellaOps.Notify.WebService.Options;
/// <summary>
/// Strongly typed configuration for the Notify WebService host.
/// </summary>
public sealed class NotifyWebServiceOptions
{
public const string SectionName = "notify";
/// <summary>
/// Schema version that downstream consumers can use to detect breaking changes.
/// </summary>
public int SchemaVersion { get; set; } = 1;
/// <summary>
/// Authority / authentication configuration.
/// </summary>
public AuthorityOptions Authority { get; set; } = new();
/// <summary>
/// Storage configuration (PostgreSQL-only after cutover).
/// </summary>
public StorageOptions Storage { get; set; } = new();
/// <summary>
/// Plug-in loader configuration.
/// </summary>
public PluginOptions Plugins { get; set; } = new();
/// <summary>
/// HTTP API behaviour.
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <summary>
/// Telemetry configuration toggles.
/// </summary>
public TelemetryOptions Telemetry { get; set; } = new();
public sealed class AuthorityOptions
{
public bool Enabled { get; set; } = true;
public bool AllowAnonymousFallback { get; set; }
public string Issuer { get; set; } = "https://authority.local";
public string? MetadataAddress { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
/// <summary>
/// Plug-in loader configuration.
/// </summary>
public PluginOptions Plugins { get; set; } = new();
/// <summary>
/// HTTP API behaviour.
/// </summary>
public ApiOptions Api { get; set; } = new();
/// <summary>
/// Telemetry configuration toggles.
/// </summary>
public TelemetryOptions Telemetry { get; set; } = new();
public sealed class AuthorityOptions
{
public bool Enabled { get; set; } = true;
public bool AllowAnonymousFallback { get; set; }
public string Issuer { get; set; } = "https://authority.local";
public string? MetadataAddress { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public IList<string> Audiences { get; set; } = new List<string> { "notify" };
public string ViewerScope { get; set; } = "notify.viewer";
@@ -62,89 +62,89 @@ public sealed class NotifyWebServiceOptions
public string OperatorScope { get; set; } = "notify.operator";
public string AdminScope { get; set; } = "notify.admin";
/// <summary>
/// Optional development signing key for symmetric JWT validation when Authority is disabled.
/// </summary>
public string? DevelopmentSigningKey { get; set; }
}
public sealed class StorageOptions
{
/// <summary>
/// Optional development signing key for symmetric JWT validation when Authority is disabled.
/// </summary>
public string? DevelopmentSigningKey { get; set; }
}
public sealed class StorageOptions
{
public string Driver { get; set; } = "postgres";
public string ConnectionString { get; set; } = string.Empty;
public string Database { get; set; } = "notify";
public int CommandTimeoutSeconds { get; set; } = 30;
}
public sealed class PluginOptions
{
public string? BaseDirectory { get; set; }
public string? Directory { get; set; }
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> OrderedPlugins { get; set; } = new List<string>();
}
public sealed class ApiOptions
{
public string BasePath { get; set; } = "/api/v1/notify";
public string InternalBasePath { get; set; } = "/internal/notify";
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
public RateLimitOptions RateLimits { get; set; } = new();
}
public sealed class RateLimitOptions
{
public RateLimitPolicyOptions DeliveryHistory { get; set; } = RateLimitPolicyOptions.CreateDefault(
tokenLimit: 60,
tokensPerPeriod: 30,
replenishmentPeriodSeconds: 60,
queueLimit: 20);
public RateLimitPolicyOptions TestSend { get; set; } = RateLimitPolicyOptions.CreateDefault(
tokenLimit: 5,
tokensPerPeriod: 5,
replenishmentPeriodSeconds: 60,
queueLimit: 2);
}
public sealed class RateLimitPolicyOptions
{
public bool Enabled { get; set; } = true;
public int TokenLimit { get; set; } = 10;
public int TokensPerPeriod { get; set; } = 10;
public int ReplenishmentPeriodSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 0;
public static RateLimitPolicyOptions CreateDefault(int tokenLimit, int tokensPerPeriod, int replenishmentPeriodSeconds, int queueLimit)
{
return new RateLimitPolicyOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriodSeconds = replenishmentPeriodSeconds,
QueueLimit = queueLimit
};
}
}
public sealed class TelemetryOptions
{
public bool EnableRequestLogging { get; set; } = true;
public string MinimumLogLevel { get; set; } = "Information";
}
}
public string ConnectionString { get; set; } = string.Empty;
public string Database { get; set; } = "notify";
public int CommandTimeoutSeconds { get; set; } = 30;
}
public sealed class PluginOptions
{
public string? BaseDirectory { get; set; }
public string? Directory { get; set; }
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> OrderedPlugins { get; set; } = new List<string>();
}
public sealed class ApiOptions
{
public string BasePath { get; set; } = "/api/v1/notify";
public string InternalBasePath { get; set; } = "/internal/notify";
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
public RateLimitOptions RateLimits { get; set; } = new();
}
public sealed class RateLimitOptions
{
public RateLimitPolicyOptions DeliveryHistory { get; set; } = RateLimitPolicyOptions.CreateDefault(
tokenLimit: 60,
tokensPerPeriod: 30,
replenishmentPeriodSeconds: 60,
queueLimit: 20);
public RateLimitPolicyOptions TestSend { get; set; } = RateLimitPolicyOptions.CreateDefault(
tokenLimit: 5,
tokensPerPeriod: 5,
replenishmentPeriodSeconds: 60,
queueLimit: 2);
}
public sealed class RateLimitPolicyOptions
{
public bool Enabled { get; set; } = true;
public int TokenLimit { get; set; } = 10;
public int TokensPerPeriod { get; set; } = 10;
public int ReplenishmentPeriodSeconds { get; set; } = 60;
public int QueueLimit { get; set; } = 0;
public static RateLimitPolicyOptions CreateDefault(int tokenLimit, int tokensPerPeriod, int replenishmentPeriodSeconds, int queueLimit)
{
return new RateLimitPolicyOptions
{
TokenLimit = tokenLimit,
TokensPerPeriod = tokensPerPeriod,
ReplenishmentPeriodSeconds = replenishmentPeriodSeconds,
QueueLimit = queueLimit
};
}
}
public sealed class TelemetryOptions
{
public bool EnableRequestLogging { get; set; } = true;
public string MinimumLogLevel { get; set; } = "Information";
}
}

View File

@@ -1,47 +1,47 @@
using System;
using System.IO;
namespace StellaOps.Notify.WebService.Options;
internal static class NotifyWebServiceOptionsPostConfigure
{
public static void Apply(NotifyWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
NormalizePluginOptions(options.Plugins, contentRootPath);
}
private static void NormalizePluginOptions(NotifyWebServiceOptions.PluginOptions plugins, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(plugins);
var baseDirectory = plugins.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = Path.Combine(contentRootPath, "..");
}
else if (!Path.IsPathRooted(baseDirectory))
{
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
}
plugins.BaseDirectory = baseDirectory;
if (string.IsNullOrWhiteSpace(plugins.Directory))
{
plugins.Directory = Path.Combine("plugins", "notify");
}
if (!Path.IsPathRooted(plugins.Directory))
{
plugins.Directory = Path.Combine(baseDirectory, plugins.Directory);
}
if (plugins.SearchPatterns.Count == 0)
{
plugins.SearchPatterns.Add("StellaOps.Notify.Connectors.*.dll");
}
}
}
using System;
using System.IO;
namespace StellaOps.Notify.WebService.Options;
internal static class NotifyWebServiceOptionsPostConfigure
{
public static void Apply(NotifyWebServiceOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(contentRootPath);
NormalizePluginOptions(options.Plugins, contentRootPath);
}
private static void NormalizePluginOptions(NotifyWebServiceOptions.PluginOptions plugins, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(plugins);
var baseDirectory = plugins.BaseDirectory;
if (string.IsNullOrWhiteSpace(baseDirectory))
{
baseDirectory = Path.Combine(contentRootPath, "..");
}
else if (!Path.IsPathRooted(baseDirectory))
{
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
}
plugins.BaseDirectory = baseDirectory;
if (string.IsNullOrWhiteSpace(plugins.Directory))
{
plugins.Directory = Path.Combine("plugins", "notify");
}
if (!Path.IsPathRooted(plugins.Directory))
{
plugins.Directory = Path.Combine(baseDirectory, plugins.Directory);
}
if (plugins.SearchPatterns.Count == 0)
{
plugins.SearchPatterns.Add("StellaOps.Notify.Connectors.*.dll");
}
}
}

View File

@@ -1,119 +1,119 @@
using System;
using System.Linq;
namespace StellaOps.Notify.WebService.Options;
internal static class NotifyWebServiceOptionsValidator
{
public static void Validate(NotifyWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ValidateStorage(options.Storage);
ValidateAuthority(options.Authority);
ValidateApi(options.Api);
}
private static void ValidateStorage(NotifyWebServiceOptions.StorageOptions storage)
{
ArgumentNullException.ThrowIfNull(storage);
var driver = storage.Driver ?? string.Empty;
using System;
using System.Linq;
namespace StellaOps.Notify.WebService.Options;
internal static class NotifyWebServiceOptionsValidator
{
public static void Validate(NotifyWebServiceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ValidateStorage(options.Storage);
ValidateAuthority(options.Authority);
ValidateApi(options.Api);
}
private static void ValidateStorage(NotifyWebServiceOptions.StorageOptions storage)
{
ArgumentNullException.ThrowIfNull(storage);
var driver = storage.Driver ?? string.Empty;
if (!string.Equals(driver, "postgres", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'. Only 'postgres' is supported after cutover.");
}
}
private static void ValidateAuthority(NotifyWebServiceOptions.AuthorityOptions authority)
{
ArgumentNullException.ThrowIfNull(authority);
if (authority.Enabled)
{
if (string.IsNullOrWhiteSpace(authority.Issuer))
{
throw new InvalidOperationException("notify:authority:issuer must be provided when authority is enabled.");
}
if (authority.Audiences is null || authority.Audiences.Count == 0)
{
throw new InvalidOperationException("notify:authority:audiences must include at least one value.");
}
}
private static void ValidateAuthority(NotifyWebServiceOptions.AuthorityOptions authority)
{
ArgumentNullException.ThrowIfNull(authority);
if (authority.Enabled)
{
if (string.IsNullOrWhiteSpace(authority.Issuer))
{
throw new InvalidOperationException("notify:authority:issuer must be provided when authority is enabled.");
}
if (authority.Audiences is null || authority.Audiences.Count == 0)
{
throw new InvalidOperationException("notify:authority:audiences must include at least one value.");
}
if (string.IsNullOrWhiteSpace(authority.AdminScope)
|| string.IsNullOrWhiteSpace(authority.OperatorScope)
|| string.IsNullOrWhiteSpace(authority.ViewerScope))
{
throw new InvalidOperationException("notify:authority admin, operator, and viewer scopes must be configured.");
}
}
else
{
if (string.IsNullOrWhiteSpace(authority.DevelopmentSigningKey) || authority.DevelopmentSigningKey.Length < 32)
{
throw new InvalidOperationException("notify:authority:developmentSigningKey must be at least 32 characters when authority is disabled.");
}
}
}
private static void ValidateApi(NotifyWebServiceOptions.ApiOptions api)
{
ArgumentNullException.ThrowIfNull(api);
if (!api.BasePath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException("notify:api:basePath must start with '/'.");
}
if (!api.InternalBasePath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException("notify:api:internalBasePath must start with '/'.");
}
if (string.IsNullOrWhiteSpace(api.TenantHeader))
{
throw new InvalidOperationException("notify:api:tenantHeader must be provided.");
}
ValidateRateLimits(api.RateLimits);
}
private static void ValidateRateLimits(NotifyWebServiceOptions.RateLimitOptions rateLimits)
{
ArgumentNullException.ThrowIfNull(rateLimits);
ValidatePolicy(rateLimits.DeliveryHistory, "notify:api:rateLimits:deliveryHistory");
ValidatePolicy(rateLimits.TestSend, "notify:api:rateLimits:testSend");
static void ValidatePolicy(NotifyWebServiceOptions.RateLimitPolicyOptions options, string prefix)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled)
{
return;
}
if (options.TokenLimit <= 0)
{
throw new InvalidOperationException($"{prefix}:tokenLimit must be positive when enabled.");
}
if (options.TokensPerPeriod <= 0)
{
throw new InvalidOperationException($"{prefix}:tokensPerPeriod must be positive when enabled.");
}
if (options.ReplenishmentPeriodSeconds <= 0)
{
throw new InvalidOperationException($"{prefix}:replenishmentPeriodSeconds must be positive when enabled.");
}
if (options.QueueLimit < 0)
{
throw new InvalidOperationException($"{prefix}:queueLimit cannot be negative.");
}
}
}
}
}
}
else
{
if (string.IsNullOrWhiteSpace(authority.DevelopmentSigningKey) || authority.DevelopmentSigningKey.Length < 32)
{
throw new InvalidOperationException("notify:authority:developmentSigningKey must be at least 32 characters when authority is disabled.");
}
}
}
private static void ValidateApi(NotifyWebServiceOptions.ApiOptions api)
{
ArgumentNullException.ThrowIfNull(api);
if (!api.BasePath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException("notify:api:basePath must start with '/'.");
}
if (!api.InternalBasePath.StartsWith("/", StringComparison.Ordinal))
{
throw new InvalidOperationException("notify:api:internalBasePath must start with '/'.");
}
if (string.IsNullOrWhiteSpace(api.TenantHeader))
{
throw new InvalidOperationException("notify:api:tenantHeader must be provided.");
}
ValidateRateLimits(api.RateLimits);
}
private static void ValidateRateLimits(NotifyWebServiceOptions.RateLimitOptions rateLimits)
{
ArgumentNullException.ThrowIfNull(rateLimits);
ValidatePolicy(rateLimits.DeliveryHistory, "notify:api:rateLimits:deliveryHistory");
ValidatePolicy(rateLimits.TestSend, "notify:api:rateLimits:testSend");
static void ValidatePolicy(NotifyWebServiceOptions.RateLimitPolicyOptions options, string prefix)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Enabled)
{
return;
}
if (options.TokenLimit <= 0)
{
throw new InvalidOperationException($"{prefix}:tokenLimit must be positive when enabled.");
}
if (options.TokensPerPeriod <= 0)
{
throw new InvalidOperationException($"{prefix}:tokensPerPeriod must be positive when enabled.");
}
if (options.ReplenishmentPeriodSeconds <= 0)
{
throw new InvalidOperationException($"{prefix}:replenishmentPeriodSeconds must be positive when enabled.");
}
if (options.QueueLimit < 0)
{
throw new InvalidOperationException($"{prefix}:queueLimit cannot be negative.");
}
}
}
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Notify.WebService.Plugins;
internal interface INotifyPluginRegistry
{
Task<int> WarmupAsync(CancellationToken cancellationToken = default);
}
internal sealed class NotifyPluginRegistry : INotifyPluginRegistry
{
private readonly PluginHostOptions _hostOptions;
private readonly ILogger<NotifyPluginRegistry> _logger;
public NotifyPluginRegistry(
PluginHostOptions hostOptions,
ILogger<NotifyPluginRegistry> logger)
{
_hostOptions = hostOptions ?? throw new ArgumentNullException(nameof(hostOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<int> WarmupAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var result = PluginHost.LoadPlugins(_hostOptions, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning(
"No Notify plug-ins discovered under '{PluginDirectory}'.",
result.PluginDirectory);
}
else
{
_logger.LogInformation(
"Loaded {PluginCount} Notify plug-in(s) from '{PluginDirectory}'.",
result.Plugins.Count,
result.PluginDirectory);
}
if (result.MissingOrderedPlugins.Count > 0)
{
_logger.LogWarning(
"Configured plug-ins missing from disk: {Missing}.",
string.Join(", ", result.MissingOrderedPlugins));
}
return Task.FromResult(result.Plugins.Count);
}
}
using System;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Notify.WebService.Plugins;
internal interface INotifyPluginRegistry
{
Task<int> WarmupAsync(CancellationToken cancellationToken = default);
}
internal sealed class NotifyPluginRegistry : INotifyPluginRegistry
{
private readonly PluginHostOptions _hostOptions;
private readonly ILogger<NotifyPluginRegistry> _logger;
public NotifyPluginRegistry(
PluginHostOptions hostOptions,
ILogger<NotifyPluginRegistry> logger)
{
_hostOptions = hostOptions ?? throw new ArgumentNullException(nameof(hostOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<int> WarmupAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var result = PluginHost.LoadPlugins(_hostOptions, _logger);
if (result.Plugins.Count == 0)
{
_logger.LogWarning(
"No Notify plug-ins discovered under '{PluginDirectory}'.",
result.PluginDirectory);
}
else
{
_logger.LogInformation(
"Loaded {PluginCount} Notify plug-in(s) from '{PluginDirectory}'.",
result.Plugins.Count,
result.PluginDirectory);
}
if (result.MissingOrderedPlugins.Count > 0)
{
_logger.LogWarning(
"Configured plug-ins missing from disk: {Missing}.",
string.Join(", ", result.MissingOrderedPlugins));
}
return Task.FromResult(result.Plugins.Count);
}
}

View File

@@ -1,3 +1,3 @@
namespace StellaOps.Notify.WebService;
public partial class Program;
namespace StellaOps.Notify.WebService;
public partial class Program;

View File

@@ -1,5 +1,5 @@
namespace StellaOps.Notify.WebService.Security;
namespace StellaOps.Notify.WebService.Security;
internal static class NotifyPolicies
{
public const string Viewer = "notify.viewer";

View File

@@ -1,8 +1,8 @@
namespace StellaOps.Notify.WebService.Security;
internal static class NotifyRateLimitPolicies
{
public const string DeliveryHistory = "notify-deliveries";
public const string TestSend = "notify-test-send";
}
namespace StellaOps.Notify.WebService.Security;
internal static class NotifyRateLimitPolicies
{
public const string DeliveryHistory = "notify-deliveries";
public const string TestSend = "notify-test-send";
}

View File

@@ -1,182 +1,182 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelHealthService
{
Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelHealthService : INotifyChannelHealthService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelHealthService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> _providers;
public NotifyChannelHealthService(
TimeProvider timeProvider,
ILogger<NotifyChannelHealthService> logger,
IEnumerable<INotifyChannelHealthProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelHealthProvider>(), _logger);
}
public async Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
cancellationToken.ThrowIfCancellationRequested();
var target = ResolveTarget(channel);
var timestamp = _timeProvider.GetUtcNow();
var context = new ChannelHealthContext(
tenantId,
channel,
target,
timestamp,
traceId);
ChannelHealthResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.CheckAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Notify channel health provider {Provider} failed for tenant {TenantId}, channel {ChannelId} ({ChannelType}).",
provider.GetType().FullName,
tenantId,
channel.ChannelId,
channel.Type);
providerResult = new ChannelHealthResult(
ChannelHealthStatus.Degraded,
"Channel health provider threw an exception. See logs for details.",
new Dictionary<string, string>(StringComparer.Ordinal));
}
}
var metadata = MergeMetadata(context, providerName, providerResult?.Metadata);
var status = providerResult?.Status ?? ChannelHealthStatus.Healthy;
var message = providerResult?.Message ?? "Channel metadata returned without provider-specific diagnostics.";
var response = new ChannelHealthResponse(
tenantId,
channel.ChannelId,
status,
message,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify channel health generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> BuildProviderMap(
IEnumerable<INotifyChannelHealthProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelHealthProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel health providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static string ResolveTarget(NotifyChannel channel)
{
var target = channel.Config.Target ?? channel.Config.Endpoint;
if (string.IsNullOrWhiteSpace(target))
{
return channel.Name;
}
return target;
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelHealthContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId,
["channelEnabled"] = context.Channel.Enabled.ToString()
};
foreach (var label in context.Channel.Labels)
{
metadata[$"label.{label.Key}"] = label.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelHealthService
{
Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelHealthService : INotifyChannelHealthService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelHealthService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> _providers;
public NotifyChannelHealthService(
TimeProvider timeProvider,
ILogger<NotifyChannelHealthService> logger,
IEnumerable<INotifyChannelHealthProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelHealthProvider>(), _logger);
}
public async Task<ChannelHealthResponse> CheckAsync(
string tenantId,
NotifyChannel channel,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
cancellationToken.ThrowIfCancellationRequested();
var target = ResolveTarget(channel);
var timestamp = _timeProvider.GetUtcNow();
var context = new ChannelHealthContext(
tenantId,
channel,
target,
timestamp,
traceId);
ChannelHealthResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.CheckAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Notify channel health provider {Provider} failed for tenant {TenantId}, channel {ChannelId} ({ChannelType}).",
provider.GetType().FullName,
tenantId,
channel.ChannelId,
channel.Type);
providerResult = new ChannelHealthResult(
ChannelHealthStatus.Degraded,
"Channel health provider threw an exception. See logs for details.",
new Dictionary<string, string>(StringComparer.Ordinal));
}
}
var metadata = MergeMetadata(context, providerName, providerResult?.Metadata);
var status = providerResult?.Status ?? ChannelHealthStatus.Healthy;
var message = providerResult?.Message ?? "Channel metadata returned without provider-specific diagnostics.";
var response = new ChannelHealthResponse(
tenantId,
channel.ChannelId,
status,
message,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify channel health generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> BuildProviderMap(
IEnumerable<INotifyChannelHealthProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelHealthProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel health providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static string ResolveTarget(NotifyChannel channel)
{
var target = channel.Config.Target ?? channel.Config.Endpoint;
if (string.IsNullOrWhiteSpace(target))
{
return channel.Name;
}
return target;
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelHealthContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId,
["channelEnabled"] = context.Channel.Enabled.ToString()
};
foreach (var label in context.Channel.Labels)
{
metadata[$"label.{label.Key}"] = label.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
}

View File

@@ -1,313 +1,313 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelTestService
{
Task<ChannelTestSendResponse> SendAsync(
string tenantId,
NotifyChannel channel,
ChannelTestSendRequest request,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelTestService : INotifyChannelTestService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelTestService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> _providers;
public NotifyChannelTestService(
TimeProvider timeProvider,
ILogger<NotifyChannelTestService> logger,
IEnumerable<INotifyChannelTestProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelTestProvider>(), _logger);
}
public async Task<ChannelTestSendResponse> SendAsync(
string tenantId,
NotifyChannel channel,
ChannelTestSendRequest request,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
if (!channel.Enabled)
{
throw new ChannelTestSendValidationException("Channel is disabled. Enable it before issuing test sends.");
}
var target = ResolveTarget(channel, request.Target);
var timestamp = _timeProvider.GetUtcNow();
var previewRequest = BuildPreviewRequest(request);
var context = new ChannelTestPreviewContext(
tenantId,
channel,
target,
previewRequest,
timestamp,
traceId);
ChannelTestPreviewResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.BuildPreviewAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (ChannelTestPreviewException ex)
{
throw new ChannelTestSendValidationException(ex.Message);
}
}
var rendered = providerResult is not null
? EnsureBodyHash(providerResult.Preview)
: CreateFallbackPreview(context);
var metadata = MergeMetadata(
context,
providerName,
providerResult?.Metadata);
var response = new ChannelTestSendResponse(
tenantId,
channel.ChannelId,
rendered,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify test send preview generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> BuildProviderMap(
IEnumerable<INotifyChannelTestProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelTestProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel test providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static ChannelTestPreviewRequest BuildPreviewRequest(ChannelTestSendRequest request)
{
return new ChannelTestPreviewRequest(
TrimToNull(request.Target),
TrimToNull(request.TemplateId),
TrimToNull(request.Title),
TrimToNull(request.Summary),
request.Body,
TrimToNull(request.TextBody),
TrimToLowerInvariant(request.Locale),
NormalizeInputMetadata(request.Metadata),
NormalizeAttachments(request.AttachmentRefs));
}
private static string ResolveTarget(NotifyChannel channel, string? overrideTarget)
{
var target = string.IsNullOrWhiteSpace(overrideTarget)
? channel.Config.Target ?? channel.Config.Endpoint
: overrideTarget.Trim();
if (string.IsNullOrWhiteSpace(target))
{
throw new ChannelTestSendValidationException("Channel target is required. Provide 'target' or configure channel.config.target/endpoint.");
}
return target;
}
private static NotifyDeliveryRendered CreateFallbackPreview(ChannelTestPreviewContext context)
{
var format = MapFormat(context.Channel.Type);
var title = context.Request.Title ?? $"Stella Ops Notify Test ({context.Channel.Name})";
var body = context.Request.Body ?? BuildDefaultBody(context.Channel, context.Timestamp);
var summary = context.Request.Summary ?? $"Preview generated for {context.Channel.Type} destination.";
return NotifyDeliveryRendered.Create(
context.Channel.Type,
format,
context.Target,
title,
body,
summary,
context.Request.TextBody,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(body),
context.Request.Attachments);
}
private static NotifyDeliveryRendered EnsureBodyHash(NotifyDeliveryRendered preview)
{
if (!string.IsNullOrWhiteSpace(preview.BodyHash))
{
return preview;
}
var hash = ChannelTestPreviewUtilities.ComputeBodyHash(preview.Body);
return NotifyDeliveryRendered.Create(
preview.ChannelType,
preview.Format,
preview.Target,
preview.Title,
preview.Body,
preview.Summary,
preview.TextBody,
preview.Locale,
hash,
preview.Attachments);
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelTestPreviewContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId
};
foreach (var pair in context.Request.Metadata)
{
metadata[pair.Key] = pair.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
private static NotifyDeliveryFormat MapFormat(NotifyChannelType type)
=> type switch
{
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
_ => NotifyDeliveryFormat.Json
};
private static string BuildDefaultBody(NotifyChannel channel, DateTimeOffset timestamp)
{
return $"This is a Stella Ops Notify test message for channel '{channel.Name}' " +
$"({channel.ChannelId}, type {channel.Type}). Generated at {timestamp:O}.";
}
private static IReadOnlyDictionary<string, string> NormalizeInputMetadata(IDictionary<string, string>? source)
{
if (source is null || source.Count == 0)
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
var result = new Dictionary<string, string>(source.Count, StringComparer.Ordinal);
foreach (var pair in source)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
result[pair.Key.Trim()] = pair.Value.Trim();
}
return result;
}
private static IReadOnlyList<string> NormalizeAttachments(IList<string>? attachments)
{
if (attachments is null || attachments.Count == 0)
{
return Array.Empty<string>();
}
var list = new List<string>(attachments.Count);
foreach (var attachment in attachments)
{
if (string.IsNullOrWhiteSpace(attachment))
{
continue;
}
list.Add(attachment.Trim());
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? TrimToLowerInvariant(string? value)
{
var trimmed = TrimToNull(value);
return trimmed?.ToLowerInvariant();
}
}
internal sealed class ChannelTestSendValidationException : Exception
{
public ChannelTestSendValidationException(string message)
: base(message)
{
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using StellaOps.Notify.WebService.Contracts;
namespace StellaOps.Notify.WebService.Services;
internal interface INotifyChannelTestService
{
Task<ChannelTestSendResponse> SendAsync(
string tenantId,
NotifyChannel channel,
ChannelTestSendRequest request,
string traceId,
CancellationToken cancellationToken);
}
internal sealed class NotifyChannelTestService : INotifyChannelTestService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NotifyChannelTestService> _logger;
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> _providers;
public NotifyChannelTestService(
TimeProvider timeProvider,
ILogger<NotifyChannelTestService> logger,
IEnumerable<INotifyChannelTestProvider> providers)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelTestProvider>(), _logger);
}
public async Task<ChannelTestSendResponse> SendAsync(
string tenantId,
NotifyChannel channel,
ChannelTestSendRequest request,
string traceId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
if (!channel.Enabled)
{
throw new ChannelTestSendValidationException("Channel is disabled. Enable it before issuing test sends.");
}
var target = ResolveTarget(channel, request.Target);
var timestamp = _timeProvider.GetUtcNow();
var previewRequest = BuildPreviewRequest(request);
var context = new ChannelTestPreviewContext(
tenantId,
channel,
target,
previewRequest,
timestamp,
traceId);
ChannelTestPreviewResult? providerResult = null;
var providerName = "fallback";
if (_providers.TryGetValue(channel.Type, out var provider))
{
try
{
providerResult = await provider.BuildPreviewAsync(context, cancellationToken).ConfigureAwait(false);
providerName = provider.GetType().FullName ?? provider.GetType().Name;
}
catch (ChannelTestPreviewException ex)
{
throw new ChannelTestSendValidationException(ex.Message);
}
}
var rendered = providerResult is not null
? EnsureBodyHash(providerResult.Preview)
: CreateFallbackPreview(context);
var metadata = MergeMetadata(
context,
providerName,
providerResult?.Metadata);
var response = new ChannelTestSendResponse(
tenantId,
channel.ChannelId,
rendered,
timestamp,
traceId,
metadata);
_logger.LogInformation(
"Notify test send preview generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
tenantId,
channel.ChannelId,
channel.Type,
providerName);
return response;
}
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> BuildProviderMap(
IEnumerable<INotifyChannelTestProvider> providers,
ILogger logger)
{
var map = new Dictionary<NotifyChannelType, INotifyChannelTestProvider>();
foreach (var provider in providers)
{
if (provider is null)
{
continue;
}
if (map.TryGetValue(provider.ChannelType, out var existing))
{
logger?.LogWarning(
"Multiple Notify channel test providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
provider.ChannelType,
existing.GetType().FullName,
provider.GetType().FullName);
continue;
}
map[provider.ChannelType] = provider;
}
return map;
}
private static ChannelTestPreviewRequest BuildPreviewRequest(ChannelTestSendRequest request)
{
return new ChannelTestPreviewRequest(
TrimToNull(request.Target),
TrimToNull(request.TemplateId),
TrimToNull(request.Title),
TrimToNull(request.Summary),
request.Body,
TrimToNull(request.TextBody),
TrimToLowerInvariant(request.Locale),
NormalizeInputMetadata(request.Metadata),
NormalizeAttachments(request.AttachmentRefs));
}
private static string ResolveTarget(NotifyChannel channel, string? overrideTarget)
{
var target = string.IsNullOrWhiteSpace(overrideTarget)
? channel.Config.Target ?? channel.Config.Endpoint
: overrideTarget.Trim();
if (string.IsNullOrWhiteSpace(target))
{
throw new ChannelTestSendValidationException("Channel target is required. Provide 'target' or configure channel.config.target/endpoint.");
}
return target;
}
private static NotifyDeliveryRendered CreateFallbackPreview(ChannelTestPreviewContext context)
{
var format = MapFormat(context.Channel.Type);
var title = context.Request.Title ?? $"Stella Ops Notify Test ({context.Channel.Name})";
var body = context.Request.Body ?? BuildDefaultBody(context.Channel, context.Timestamp);
var summary = context.Request.Summary ?? $"Preview generated for {context.Channel.Type} destination.";
return NotifyDeliveryRendered.Create(
context.Channel.Type,
format,
context.Target,
title,
body,
summary,
context.Request.TextBody,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(body),
context.Request.Attachments);
}
private static NotifyDeliveryRendered EnsureBodyHash(NotifyDeliveryRendered preview)
{
if (!string.IsNullOrWhiteSpace(preview.BodyHash))
{
return preview;
}
var hash = ChannelTestPreviewUtilities.ComputeBodyHash(preview.Body);
return NotifyDeliveryRendered.Create(
preview.ChannelType,
preview.Format,
preview.Target,
preview.Title,
preview.Body,
preview.Summary,
preview.TextBody,
preview.Locale,
hash,
preview.Attachments);
}
private static IReadOnlyDictionary<string, string> MergeMetadata(
ChannelTestPreviewContext context,
string providerName,
IReadOnlyDictionary<string, string>? providerMetadata)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
["target"] = context.Target,
["previewProvider"] = providerName,
["traceId"] = context.TraceId
};
foreach (var pair in context.Request.Metadata)
{
metadata[pair.Key] = pair.Value;
}
if (providerMetadata is not null)
{
foreach (var pair in providerMetadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
metadata[pair.Key.Trim()] = pair.Value;
}
}
return metadata;
}
private static NotifyDeliveryFormat MapFormat(NotifyChannelType type)
=> type switch
{
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
_ => NotifyDeliveryFormat.Json
};
private static string BuildDefaultBody(NotifyChannel channel, DateTimeOffset timestamp)
{
return $"This is a Stella Ops Notify test message for channel '{channel.Name}' " +
$"({channel.ChannelId}, type {channel.Type}). Generated at {timestamp:O}.";
}
private static IReadOnlyDictionary<string, string> NormalizeInputMetadata(IDictionary<string, string>? source)
{
if (source is null || source.Count == 0)
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
var result = new Dictionary<string, string>(source.Count, StringComparer.Ordinal);
foreach (var pair in source)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
result[pair.Key.Trim()] = pair.Value.Trim();
}
return result;
}
private static IReadOnlyList<string> NormalizeAttachments(IList<string>? attachments)
{
if (attachments is null || attachments.Count == 0)
{
return Array.Empty<string>();
}
var list = new List<string>(attachments.Count);
foreach (var attachment in attachments)
{
if (string.IsNullOrWhiteSpace(attachment))
{
continue;
}
list.Add(attachment.Trim());
}
return list.Count == 0 ? Array.Empty<string>() : list;
}
private static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string? TrimToLowerInvariant(string? value)
{
var trimmed = TrimToNull(value);
return trimmed?.ToLowerInvariant();
}
}
internal sealed class ChannelTestSendValidationException : Exception
{
public ChannelTestSendValidationException(string message)
: base(message)
{
}
}

View File

@@ -1,17 +1,17 @@
using System;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Services;
internal sealed class NotifySchemaMigrationService
{
public NotifyRule UpgradeRule(JsonNode json)
=> NotifySchemaMigration.UpgradeRule(json ?? throw new ArgumentNullException(nameof(json)));
public NotifyChannel UpgradeChannel(JsonNode json)
=> NotifySchemaMigration.UpgradeChannel(json ?? throw new ArgumentNullException(nameof(json)));
public NotifyTemplate UpgradeTemplate(JsonNode json)
=> NotifySchemaMigration.UpgradeTemplate(json ?? throw new ArgumentNullException(nameof(json)));
}
using System;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Services;
internal sealed class NotifySchemaMigrationService
{
public NotifyRule UpgradeRule(JsonNode json)
=> NotifySchemaMigration.UpgradeRule(json ?? throw new ArgumentNullException(nameof(json)));
public NotifyChannel UpgradeChannel(JsonNode json)
=> NotifySchemaMigration.UpgradeChannel(json ?? throw new ArgumentNullException(nameof(json)));
public NotifyTemplate UpgradeTemplate(JsonNode json)
=> NotifySchemaMigration.UpgradeTemplate(json ?? throw new ArgumentNullException(nameof(json)));
}

View File

@@ -1,10 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Queue;
namespace StellaOps.Notify.Worker.Handlers;
public interface INotifyEventHandler
{
Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken);
}
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Queue;
namespace StellaOps.Notify.Worker.Handlers;
public interface INotifyEventHandler
{
Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken);
}

View File

@@ -1,25 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
namespace StellaOps.Notify.Worker.Handlers;
internal sealed class NoOpNotifyEventHandler : INotifyEventHandler
{
private readonly ILogger<NoOpNotifyEventHandler> _logger;
public NoOpNotifyEventHandler(ILogger<NoOpNotifyEventHandler> logger)
{
_logger = logger;
}
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
{
_logger.LogDebug(
"No-op handler acknowledged event {EventId} (tenant {TenantId}).",
message.Event.EventId,
message.TenantId);
return Task.CompletedTask;
}
}
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
namespace StellaOps.Notify.Worker.Handlers;
internal sealed class NoOpNotifyEventHandler : INotifyEventHandler
{
private readonly ILogger<NoOpNotifyEventHandler> _logger;
public NoOpNotifyEventHandler(ILogger<NoOpNotifyEventHandler> logger)
{
_logger = logger;
}
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
{
_logger.LogDebug(
"No-op handler acknowledged event {EventId} (tenant {TenantId}).",
message.Event.EventId,
message.TenantId);
return Task.CompletedTask;
}
}

View File

@@ -1,52 +1,52 @@
using System;
namespace StellaOps.Notify.Worker;
public sealed class NotifyWorkerOptions
{
/// <summary>
/// Worker identifier prefix; defaults to machine name.
/// </summary>
public string? WorkerId { get; set; }
/// <summary>
/// Number of messages to lease per iteration.
/// </summary>
public int LeaseBatchSize { get; set; } = 16;
/// <summary>
/// Duration a lease remains active before it becomes eligible for claim.
/// </summary>
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Delay applied when no work is available.
/// </summary>
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Maximum number of event leases processed concurrently.
/// </summary>
public int MaxConcurrency { get; set; } = 4;
/// <summary>
/// Maximum number of consecutive failures before the worker delays.
/// </summary>
public int FailureBackoffThreshold { get; set; } = 3;
/// <summary>
/// Delay applied when the failure threshold is reached.
/// </summary>
public TimeSpan FailureBackoffDelay { get; set; } = TimeSpan.FromSeconds(5);
internal string ResolveWorkerId()
{
if (!string.IsNullOrWhiteSpace(WorkerId))
{
return WorkerId!;
}
var host = Environment.MachineName;
return $"{host}-{Guid.NewGuid():n}";
}
}
using System;
namespace StellaOps.Notify.Worker;
public sealed class NotifyWorkerOptions
{
/// <summary>
/// Worker identifier prefix; defaults to machine name.
/// </summary>
public string? WorkerId { get; set; }
/// <summary>
/// Number of messages to lease per iteration.
/// </summary>
public int LeaseBatchSize { get; set; } = 16;
/// <summary>
/// Duration a lease remains active before it becomes eligible for claim.
/// </summary>
public TimeSpan LeaseDuration { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Delay applied when no work is available.
/// </summary>
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// Maximum number of event leases processed concurrently.
/// </summary>
public int MaxConcurrency { get; set; } = 4;
/// <summary>
/// Maximum number of consecutive failures before the worker delays.
/// </summary>
public int FailureBackoffThreshold { get; set; } = 3;
/// <summary>
/// Delay applied when the failure threshold is reached.
/// </summary>
public TimeSpan FailureBackoffDelay { get; set; } = TimeSpan.FromSeconds(5);
internal string ResolveWorkerId()
{
if (!string.IsNullOrWhiteSpace(WorkerId))
{
return WorkerId!;
}
var host = Environment.MachineName;
return $"{host}-{Guid.NewGuid():n}";
}
}

View File

@@ -1,146 +1,146 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker.Handlers;
namespace StellaOps.Notify.Worker.Processing;
internal sealed class NotifyEventLeaseProcessor
{
private static readonly ActivitySource ActivitySource = new("StellaOps.Notify.Worker");
private readonly INotifyEventQueue _queue;
private readonly INotifyEventHandler _handler;
private readonly NotifyWorkerOptions _options;
private readonly ILogger<NotifyEventLeaseProcessor> _logger;
private readonly TimeProvider _timeProvider;
private readonly string _workerId;
public NotifyEventLeaseProcessor(
INotifyEventQueue queue,
INotifyEventHandler handler,
IOptions<NotifyWorkerOptions> options,
ILogger<NotifyEventLeaseProcessor> logger,
TimeProvider timeProvider)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_workerId = _options.ResolveWorkerId();
}
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var leaseRequest = new NotifyQueueLeaseRequest(
consumer: _workerId,
batchSize: Math.Max(1, _options.LeaseBatchSize),
leaseDuration: _options.LeaseDuration <= TimeSpan.Zero ? TimeSpan.FromSeconds(30) : _options.LeaseDuration);
IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>> leases;
try
{
leases = await _queue.LeaseAsync(leaseRequest, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to lease Notify events.");
throw;
}
if (leases.Count == 0)
{
return 0;
}
var processed = 0;
foreach (var lease in leases)
{
cancellationToken.ThrowIfCancellationRequested();
processed++;
await ProcessLeaseAsync(lease, cancellationToken).ConfigureAwait(false);
}
return processed;
}
private async Task ProcessLeaseAsync(
INotifyQueueLease<NotifyQueueEventMessage> lease,
CancellationToken cancellationToken)
{
var message = lease.Message;
var correlationId = message.TraceId ?? message.Event.EventId.ToString("N");
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["notifyTraceId"] = correlationId,
["notifyTenantId"] = message.TenantId,
["notifyEventId"] = message.Event.EventId,
["notifyAttempt"] = lease.Attempt
});
using var activity = ActivitySource.StartActivity("notify.event.process", ActivityKind.Consumer);
activity?.SetTag("notify.tenant_id", message.TenantId);
activity?.SetTag("notify.event_id", message.Event.EventId);
activity?.SetTag("notify.attempt", lease.Attempt);
activity?.SetTag("notify.worker_id", _workerId);
try
{
_logger.LogInformation(
"Processing notify event {EventId} (tenant {TenantId}, attempt {Attempt}).",
message.Event.EventId,
message.TenantId,
lease.Attempt);
await _handler.HandleAsync(message, cancellationToken).ConfigureAwait(false);
await lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Acknowledged notify event {EventId} (tenant {TenantId}).",
message.Event.EventId,
message.TenantId);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"Worker cancellation requested while processing event {EventId}; returning lease to queue.",
message.Event.EventId);
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, CancellationToken.None).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process notify event {EventId}; scheduling retry.",
message.Event.EventId);
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
}
}
private static async Task SafeReleaseAsync(
INotifyQueueLease<NotifyQueueEventMessage> lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
try
{
await lease.ReleaseAsync(disposition, cancellationToken).ConfigureAwait(false);
}
catch when (cancellationToken.IsCancellationRequested)
{
// Suppress release errors during shutdown.
}
}
}
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker.Handlers;
namespace StellaOps.Notify.Worker.Processing;
internal sealed class NotifyEventLeaseProcessor
{
private static readonly ActivitySource ActivitySource = new("StellaOps.Notify.Worker");
private readonly INotifyEventQueue _queue;
private readonly INotifyEventHandler _handler;
private readonly NotifyWorkerOptions _options;
private readonly ILogger<NotifyEventLeaseProcessor> _logger;
private readonly TimeProvider _timeProvider;
private readonly string _workerId;
public NotifyEventLeaseProcessor(
INotifyEventQueue queue,
INotifyEventHandler handler,
IOptions<NotifyWorkerOptions> options,
ILogger<NotifyEventLeaseProcessor> logger,
TimeProvider timeProvider)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_workerId = _options.ResolveWorkerId();
}
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var leaseRequest = new NotifyQueueLeaseRequest(
consumer: _workerId,
batchSize: Math.Max(1, _options.LeaseBatchSize),
leaseDuration: _options.LeaseDuration <= TimeSpan.Zero ? TimeSpan.FromSeconds(30) : _options.LeaseDuration);
IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>> leases;
try
{
leases = await _queue.LeaseAsync(leaseRequest, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to lease Notify events.");
throw;
}
if (leases.Count == 0)
{
return 0;
}
var processed = 0;
foreach (var lease in leases)
{
cancellationToken.ThrowIfCancellationRequested();
processed++;
await ProcessLeaseAsync(lease, cancellationToken).ConfigureAwait(false);
}
return processed;
}
private async Task ProcessLeaseAsync(
INotifyQueueLease<NotifyQueueEventMessage> lease,
CancellationToken cancellationToken)
{
var message = lease.Message;
var correlationId = message.TraceId ?? message.Event.EventId.ToString("N");
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["notifyTraceId"] = correlationId,
["notifyTenantId"] = message.TenantId,
["notifyEventId"] = message.Event.EventId,
["notifyAttempt"] = lease.Attempt
});
using var activity = ActivitySource.StartActivity("notify.event.process", ActivityKind.Consumer);
activity?.SetTag("notify.tenant_id", message.TenantId);
activity?.SetTag("notify.event_id", message.Event.EventId);
activity?.SetTag("notify.attempt", lease.Attempt);
activity?.SetTag("notify.worker_id", _workerId);
try
{
_logger.LogInformation(
"Processing notify event {EventId} (tenant {TenantId}, attempt {Attempt}).",
message.Event.EventId,
message.TenantId,
lease.Attempt);
await _handler.HandleAsync(message, cancellationToken).ConfigureAwait(false);
await lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Acknowledged notify event {EventId} (tenant {TenantId}).",
message.Event.EventId,
message.TenantId);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
"Worker cancellation requested while processing event {EventId}; returning lease to queue.",
message.Event.EventId);
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, CancellationToken.None).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process notify event {EventId}; scheduling retry.",
message.Event.EventId);
await SafeReleaseAsync(lease, NotifyQueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
}
}
private static async Task SafeReleaseAsync(
INotifyQueueLease<NotifyQueueEventMessage> lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
try
{
await lease.ReleaseAsync(disposition, cancellationToken).ConfigureAwait(false);
}
catch when (cancellationToken.IsCancellationRequested)
{
// Suppress release errors during shutdown.
}
}
}

View File

@@ -1,63 +1,63 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notify.Worker.Processing;
internal sealed class NotifyEventLeaseWorker : BackgroundService
{
private readonly NotifyEventLeaseProcessor _processor;
private readonly NotifyWorkerOptions _options;
private readonly ILogger<NotifyEventLeaseWorker> _logger;
public NotifyEventLeaseWorker(
NotifyEventLeaseProcessor processor,
IOptions<NotifyWorkerOptions> options,
ILogger<NotifyEventLeaseWorker> logger)
{
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var idleDelay = _options.IdleDelay <= TimeSpan.Zero
? TimeSpan.FromMilliseconds(500)
: _options.IdleDelay;
while (!stoppingToken.IsCancellationRequested)
{
int processed;
try
{
processed = await _processor.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify worker processing loop encountered an error.");
await Task.Delay(_options.FailureBackoffDelay, stoppingToken).ConfigureAwait(false);
continue;
}
if (processed == 0)
{
try
{
await Task.Delay(idleDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Notify.Worker.Processing;
internal sealed class NotifyEventLeaseWorker : BackgroundService
{
private readonly NotifyEventLeaseProcessor _processor;
private readonly NotifyWorkerOptions _options;
private readonly ILogger<NotifyEventLeaseWorker> _logger;
public NotifyEventLeaseWorker(
NotifyEventLeaseProcessor processor,
IOptions<NotifyWorkerOptions> options,
ILogger<NotifyEventLeaseWorker> logger)
{
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var idleDelay = _options.IdleDelay <= TimeSpan.Zero
? TimeSpan.FromMilliseconds(500)
: _options.IdleDelay;
while (!stoppingToken.IsCancellationRequested)
{
int processed;
try
{
processed = await _processor.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify worker processing loop encountered an error.");
await Task.Delay(_options.FailureBackoffDelay, stoppingToken).ConfigureAwait(false);
continue;
}
if (processed == 0)
{
try
{
await Task.Delay(idleDelay, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
}

View File

@@ -1,33 +1,33 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker;
using StellaOps.Notify.Worker.Handlers;
using StellaOps.Notify.Worker.Processing;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "NOTIFY_");
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
options.UseUtcTimestamp = true;
});
builder.Services.Configure<NotifyWorkerOptions>(builder.Configuration.GetSection("notify:worker"));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddNotifyEventQueue(builder.Configuration, "notify:queue");
builder.Services.AddNotifyDeliveryQueue(builder.Configuration, "notify:deliveryQueue");
builder.Services.AddSingleton<INotifyEventHandler, NoOpNotifyEventHandler>();
builder.Services.AddSingleton<NotifyEventLeaseProcessor>();
builder.Services.AddHostedService<NotifyEventLeaseWorker>();
await builder.Build().RunAsync().ConfigureAwait(false);
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker;
using StellaOps.Notify.Worker.Handlers;
using StellaOps.Notify.Worker.Processing;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "NOTIFY_");
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ ";
options.UseUtcTimestamp = true;
});
builder.Services.Configure<NotifyWorkerOptions>(builder.Configuration.GetSection("notify:worker"));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddNotifyEventQueue(builder.Configuration, "notify:queue");
builder.Services.AddNotifyDeliveryQueue(builder.Configuration, "notify:deliveryQueue");
builder.Services.AddSingleton<INotifyEventHandler, NoOpNotifyEventHandler>();
builder.Services.AddSingleton<NotifyEventLeaseProcessor>();
builder.Services.AddHostedService<NotifyEventLeaseWorker>();
await builder.Build().RunAsync().ConfigureAwait(false);

View File

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

View File

@@ -1,59 +1,59 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Email;
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
public sealed class EmailChannelHealthProvider : INotifyChannelHealthProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var builder = EmailMetadataBuilder.CreateBuilder(context)
.Add("email.channel.enabled", context.Channel.Enabled ? "true" : "false")
.Add("email.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
var metadata = builder.Build();
var status = ResolveStatus(context.Channel);
var message = status switch
{
ChannelHealthStatus.Healthy => "Email channel configuration validated.",
ChannelHealthStatus.Degraded => "Email channel is disabled; enable it to resume deliveries.",
ChannelHealthStatus.Unhealthy => "Email channel target/configuration incomplete.",
_ => "Email channel diagnostics completed."
};
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
}
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
{
if (!HasConfiguredTarget(channel))
{
return ChannelHealthStatus.Unhealthy;
}
if (!channel.Enabled)
{
return ChannelHealthStatus.Degraded;
}
return ChannelHealthStatus.Healthy;
}
private static bool HasConfiguredTarget(NotifyChannel channel)
=> !string.IsNullOrWhiteSpace(channel.Config.Target) ||
(channel.Config.Properties is not null &&
channel.Config.Properties.TryGetValue("fromAddress", out var from) &&
!string.IsNullOrWhiteSpace(from));
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Email;
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
public sealed class EmailChannelHealthProvider : INotifyChannelHealthProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var builder = EmailMetadataBuilder.CreateBuilder(context)
.Add("email.channel.enabled", context.Channel.Enabled ? "true" : "false")
.Add("email.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
var metadata = builder.Build();
var status = ResolveStatus(context.Channel);
var message = status switch
{
ChannelHealthStatus.Healthy => "Email channel configuration validated.",
ChannelHealthStatus.Degraded => "Email channel is disabled; enable it to resume deliveries.",
ChannelHealthStatus.Unhealthy => "Email channel target/configuration incomplete.",
_ => "Email channel diagnostics completed."
};
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
}
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
{
if (!HasConfiguredTarget(channel))
{
return ChannelHealthStatus.Unhealthy;
}
if (!channel.Enabled)
{
return ChannelHealthStatus.Degraded;
}
return ChannelHealthStatus.Healthy;
}
private static bool HasConfiguredTarget(NotifyChannel channel)
=> !string.IsNullOrWhiteSpace(channel.Config.Target) ||
(channel.Config.Properties is not null &&
channel.Config.Properties.TryGetValue("fromAddress", out var from) &&
!string.IsNullOrWhiteSpace(from));
}

View File

@@ -1,42 +1,42 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Email;
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
public sealed class EmailChannelTestProvider : INotifyChannelTestProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var subject = context.Request.Title ?? "Stella Ops Notify Preview";
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
var htmlBody = !string.IsNullOrWhiteSpace(context.Request.Body)
? context.Request.Body!
: $"<p>{summary}</p><p><small>Trace: {context.TraceId}</small></p>";
var textBody = context.Request.TextBody ?? $"{summary}{Environment.NewLine}Trace: {context.TraceId}";
var preview = NotifyDeliveryRendered.Create(
NotifyChannelType.Email,
NotifyDeliveryFormat.Email,
context.Target,
subject,
htmlBody,
summary,
textBody,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(htmlBody),
context.Request.Attachments);
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Email;
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
public sealed class EmailChannelTestProvider : INotifyChannelTestProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var subject = context.Request.Title ?? "Stella Ops Notify Preview";
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
var htmlBody = !string.IsNullOrWhiteSpace(context.Request.Body)
? context.Request.Body!
: $"<p>{summary}</p><p><small>Trace: {context.TraceId}</small></p>";
var textBody = context.Request.TextBody ?? $"{summary}{Environment.NewLine}Trace: {context.TraceId}";
var preview = NotifyDeliveryRendered.Create(
NotifyChannelType.Email,
NotifyDeliveryFormat.Email,
context.Target,
subject,
htmlBody,
summary,
textBody,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(htmlBody),
context.Request.Attachments);
var metadata = EmailMetadataBuilder.Build(context);
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));

View File

@@ -1,54 +1,54 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Email;
/// <summary>
/// Builds metadata for Email previews and health diagnostics with redacted secrets.
/// </summary>
internal static class EmailMetadataBuilder
{
private const int SecretHashLengthBytes = 8;
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
=> CreateBuilder(context).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
IReadOnlyDictionary<string, string>? properties,
string secretRef)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("email.target", target)
.AddTimestamp("email.preview.generatedAt", timestamp)
.AddSecretRefHash("email.secretRef.hash", secretRef, SecretHashLengthBytes)
.AddConfigProperties("email.config.", properties);
return builder;
}
}
using System;
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Email;
/// <summary>
/// Builds metadata for Email previews and health diagnostics with redacted secrets.
/// </summary>
internal static class EmailMetadataBuilder
{
private const int SecretHashLengthBytes = 8;
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
=> CreateBuilder(context).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
IReadOnlyDictionary<string, string>? properties,
string secretRef)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("email.target", target)
.AddTimestamp("email.preview.generatedAt", timestamp)
.AddSecretRefHash("email.secretRef.hash", secretRef, SecretHashLengthBytes)
.AddConfigProperties("email.config.", properties);
return builder;
}
}

View File

@@ -1,31 +1,31 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Notify.Connectors.Shared;
/// <summary>
/// Common hashing helpers for Notify connector metadata.
/// </summary>
public static class ConnectorHashing
{
/// <summary>
/// Computes a lowercase hex SHA-256 hash and truncates it to the requested number of bytes.
/// </summary>
public static string ComputeSha256Hash(string value, int lengthBytes = 8)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value must not be null or whitespace.", nameof(value));
}
if (lengthBytes <= 0 || lengthBytes > 32)
{
throw new ArgumentOutOfRangeException(nameof(lengthBytes), "Length must be between 1 and 32 bytes.");
}
var bytes = Encoding.UTF8.GetBytes(value.Trim());
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, lengthBytes)).ToLowerInvariant();
}
}
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Notify.Connectors.Shared;
/// <summary>
/// Common hashing helpers for Notify connector metadata.
/// </summary>
public static class ConnectorHashing
{
/// <summary>
/// Computes a lowercase hex SHA-256 hash and truncates it to the requested number of bytes.
/// </summary>
public static string ComputeSha256Hash(string value, int lengthBytes = 8)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value must not be null or whitespace.", nameof(value));
}
if (lengthBytes <= 0 || lengthBytes > 32)
{
throw new ArgumentOutOfRangeException(nameof(lengthBytes), "Length must be between 1 and 32 bytes.");
}
var bytes = Encoding.UTF8.GetBytes(value.Trim());
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, lengthBytes)).ToLowerInvariant();
}
}

View File

@@ -1,147 +1,147 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
namespace StellaOps.Notify.Connectors.Shared;
/// <summary>
/// Utility for constructing connector metadata payloads with consistent redaction rules.
/// </summary>
public sealed class ConnectorMetadataBuilder
{
private readonly Dictionary<string, string> _metadata;
public ConnectorMetadataBuilder(StringComparer? comparer = null)
{
_metadata = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
SensitiveFragments = new HashSet<string>(ConnectorValueRedactor.DefaultSensitiveKeyFragments, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Collection of key fragments treated as sensitive when redacting values.
/// </summary>
public ISet<string> SensitiveFragments { get; }
/// <summary>
/// Adds or replaces a metadata entry when the value is non-empty.
/// </summary>
public ConnectorMetadataBuilder Add(string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
return this;
}
_metadata[key.Trim()] = value.Trim();
return this;
}
/// <summary>
/// Adds the target value metadata. The value is trimmed but not redacted.
/// </summary>
public ConnectorMetadataBuilder AddTarget(string key, string target)
=> Add(key, target);
/// <summary>
/// Adds ISO-8601 timestamp metadata.
/// </summary>
public ConnectorMetadataBuilder AddTimestamp(string key, DateTimeOffset timestamp)
=> Add(key, timestamp.ToString("O", CultureInfo.InvariantCulture));
/// <summary>
/// Adds a hash of the secret reference when present.
/// </summary>
public ConnectorMetadataBuilder AddSecretRefHash(string key, string? secretRef, int lengthBytes = 8)
{
if (!string.IsNullOrWhiteSpace(secretRef))
{
Add(key, ConnectorHashing.ComputeSha256Hash(secretRef, lengthBytes));
}
return this;
}
/// <summary>
/// Adds configuration target metadata only when the stored configuration differs from the resolved target.
/// </summary>
public ConnectorMetadataBuilder AddConfigTarget(string key, string? configuredTarget, string resolvedTarget)
{
if (!string.IsNullOrWhiteSpace(configuredTarget) &&
!string.Equals(configuredTarget, resolvedTarget, StringComparison.Ordinal))
{
Add(key, configuredTarget);
}
return this;
}
/// <summary>
/// Adds configuration endpoint metadata when present.
/// </summary>
public ConnectorMetadataBuilder AddConfigEndpoint(string key, string? endpoint)
=> Add(key, endpoint);
/// <summary>
/// Adds key/value metadata pairs from the provided dictionary, applying redaction to sensitive entries.
/// </summary>
public ConnectorMetadataBuilder AddConfigProperties(
string prefix,
IReadOnlyDictionary<string, string>? properties,
Func<string, string, string>? valueSelector = null)
{
if (properties is null || properties.Count == 0)
{
return this;
}
foreach (var pair in properties)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
var key = prefix + pair.Key.Trim();
var value = valueSelector is null
? Redact(pair.Key, pair.Value)
: valueSelector(pair.Key, pair.Value);
Add(key, value);
}
return this;
}
/// <summary>
/// Merges additional metadata entries into the builder.
/// </summary>
public ConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string>> entries)
{
foreach (var (key, value) in entries)
{
Add(key, value);
}
return this;
}
/// <summary>
/// Returns the redacted representation for the supplied key/value pair.
/// </summary>
public string Redact(string key, string value)
{
if (ConnectorValueRedactor.IsSensitiveKey(key, SensitiveFragments))
{
return ConnectorValueRedactor.RedactSecret(value);
}
return value.Trim();
}
/// <summary>
/// Builds an immutable view of the accumulated metadata.
/// </summary>
public IReadOnlyDictionary<string, string> Build()
=> new ReadOnlyDictionary<string, string>(_metadata);
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
namespace StellaOps.Notify.Connectors.Shared;
/// <summary>
/// Utility for constructing connector metadata payloads with consistent redaction rules.
/// </summary>
public sealed class ConnectorMetadataBuilder
{
private readonly Dictionary<string, string> _metadata;
public ConnectorMetadataBuilder(StringComparer? comparer = null)
{
_metadata = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
SensitiveFragments = new HashSet<string>(ConnectorValueRedactor.DefaultSensitiveKeyFragments, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Collection of key fragments treated as sensitive when redacting values.
/// </summary>
public ISet<string> SensitiveFragments { get; }
/// <summary>
/// Adds or replaces a metadata entry when the value is non-empty.
/// </summary>
public ConnectorMetadataBuilder Add(string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
return this;
}
_metadata[key.Trim()] = value.Trim();
return this;
}
/// <summary>
/// Adds the target value metadata. The value is trimmed but not redacted.
/// </summary>
public ConnectorMetadataBuilder AddTarget(string key, string target)
=> Add(key, target);
/// <summary>
/// Adds ISO-8601 timestamp metadata.
/// </summary>
public ConnectorMetadataBuilder AddTimestamp(string key, DateTimeOffset timestamp)
=> Add(key, timestamp.ToString("O", CultureInfo.InvariantCulture));
/// <summary>
/// Adds a hash of the secret reference when present.
/// </summary>
public ConnectorMetadataBuilder AddSecretRefHash(string key, string? secretRef, int lengthBytes = 8)
{
if (!string.IsNullOrWhiteSpace(secretRef))
{
Add(key, ConnectorHashing.ComputeSha256Hash(secretRef, lengthBytes));
}
return this;
}
/// <summary>
/// Adds configuration target metadata only when the stored configuration differs from the resolved target.
/// </summary>
public ConnectorMetadataBuilder AddConfigTarget(string key, string? configuredTarget, string resolvedTarget)
{
if (!string.IsNullOrWhiteSpace(configuredTarget) &&
!string.Equals(configuredTarget, resolvedTarget, StringComparison.Ordinal))
{
Add(key, configuredTarget);
}
return this;
}
/// <summary>
/// Adds configuration endpoint metadata when present.
/// </summary>
public ConnectorMetadataBuilder AddConfigEndpoint(string key, string? endpoint)
=> Add(key, endpoint);
/// <summary>
/// Adds key/value metadata pairs from the provided dictionary, applying redaction to sensitive entries.
/// </summary>
public ConnectorMetadataBuilder AddConfigProperties(
string prefix,
IReadOnlyDictionary<string, string>? properties,
Func<string, string, string>? valueSelector = null)
{
if (properties is null || properties.Count == 0)
{
return this;
}
foreach (var pair in properties)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
var key = prefix + pair.Key.Trim();
var value = valueSelector is null
? Redact(pair.Key, pair.Value)
: valueSelector(pair.Key, pair.Value);
Add(key, value);
}
return this;
}
/// <summary>
/// Merges additional metadata entries into the builder.
/// </summary>
public ConnectorMetadataBuilder AddRange(IEnumerable<KeyValuePair<string, string>> entries)
{
foreach (var (key, value) in entries)
{
Add(key, value);
}
return this;
}
/// <summary>
/// Returns the redacted representation for the supplied key/value pair.
/// </summary>
public string Redact(string key, string value)
{
if (ConnectorValueRedactor.IsSensitiveKey(key, SensitiveFragments))
{
return ConnectorValueRedactor.RedactSecret(value);
}
return value.Trim();
}
/// <summary>
/// Builds an immutable view of the accumulated metadata.
/// </summary>
public IReadOnlyDictionary<string, string> Build()
=> new ReadOnlyDictionary<string, string>(_metadata);
}

View File

@@ -1,75 +1,75 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Connectors.Shared;
/// <summary>
/// Shared helpers for redacting sensitive connector metadata.
/// </summary>
public static class ConnectorValueRedactor
{
private static readonly string[] DefaultSensitiveFragments =
{
"token",
"secret",
"authorization",
"cookie",
"password",
"key",
"credential"
};
/// <summary>
/// Gets the default set of sensitive key fragments.
/// </summary>
public static IReadOnlyCollection<string> DefaultSensitiveKeyFragments => DefaultSensitiveFragments;
/// <summary>
/// Uses a constant mask for sensitive values.
/// </summary>
public static string RedactSecret(string value) => "***";
/// <summary>
/// Redacts the middle portion of a token while keeping stable prefix/suffix bytes.
/// </summary>
public static string RedactToken(string value, int prefixLength = 6, int suffixLength = 4)
{
var trimmed = value?.Trim() ?? string.Empty;
if (trimmed.Length <= prefixLength + suffixLength)
{
return RedactSecret(trimmed);
}
var prefix = trimmed[..prefixLength];
var suffix = trimmed[^suffixLength..];
return string.Concat(prefix, "***", suffix);
}
/// <summary>
/// Returns true when the provided key appears to represent sensitive data.
/// </summary>
public static bool IsSensitiveKey(string key, IEnumerable<string>? fragments = null)
{
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
fragments ??= DefaultSensitiveFragments;
var span = key.AsSpan();
foreach (var fragment in fragments)
{
if (string.IsNullOrWhiteSpace(fragment))
{
continue;
}
if (span.IndexOf(fragment.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
}
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Connectors.Shared;
/// <summary>
/// Shared helpers for redacting sensitive connector metadata.
/// </summary>
public static class ConnectorValueRedactor
{
private static readonly string[] DefaultSensitiveFragments =
{
"token",
"secret",
"authorization",
"cookie",
"password",
"key",
"credential"
};
/// <summary>
/// Gets the default set of sensitive key fragments.
/// </summary>
public static IReadOnlyCollection<string> DefaultSensitiveKeyFragments => DefaultSensitiveFragments;
/// <summary>
/// Uses a constant mask for sensitive values.
/// </summary>
public static string RedactSecret(string value) => "***";
/// <summary>
/// Redacts the middle portion of a token while keeping stable prefix/suffix bytes.
/// </summary>
public static string RedactToken(string value, int prefixLength = 6, int suffixLength = 4)
{
var trimmed = value?.Trim() ?? string.Empty;
if (trimmed.Length <= prefixLength + suffixLength)
{
return RedactSecret(trimmed);
}
var prefix = trimmed[..prefixLength];
var suffix = trimmed[^suffixLength..];
return string.Concat(prefix, "***", suffix);
}
/// <summary>
/// Returns true when the provided key appears to represent sensitive data.
/// </summary>
public static bool IsSensitiveKey(string key, IEnumerable<string>? fragments = null)
{
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
fragments ??= DefaultSensitiveFragments;
var span = key.AsSpan();
foreach (var fragment in fragments)
{
if (string.IsNullOrWhiteSpace(fragment))
{
continue;
}
if (span.IndexOf(fragment.AsSpan(), StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
}

View File

@@ -1,56 +1,56 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Slack;
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
public sealed class SlackChannelHealthProvider : INotifyChannelHealthProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var builder = SlackMetadataBuilder.CreateBuilder(context)
.Add("slack.channel.enabled", context.Channel.Enabled ? "true" : "false")
.Add("slack.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
var metadata = builder.Build();
var status = ResolveStatus(context.Channel);
var message = status switch
{
ChannelHealthStatus.Healthy => "Slack channel configuration validated.",
ChannelHealthStatus.Degraded => "Slack channel is disabled; enable it to resume deliveries.",
ChannelHealthStatus.Unhealthy => "Slack channel is missing a configured destination (target).",
_ => "Slack channel diagnostics completed."
};
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
}
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
{
if (!HasConfiguredTarget(channel))
{
return ChannelHealthStatus.Unhealthy;
}
if (!channel.Enabled)
{
return ChannelHealthStatus.Degraded;
}
return ChannelHealthStatus.Healthy;
}
private static bool HasConfiguredTarget(NotifyChannel channel)
=> !string.IsNullOrWhiteSpace(channel.Config.Target);
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Slack;
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
public sealed class SlackChannelHealthProvider : INotifyChannelHealthProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var builder = SlackMetadataBuilder.CreateBuilder(context)
.Add("slack.channel.enabled", context.Channel.Enabled ? "true" : "false")
.Add("slack.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
var metadata = builder.Build();
var status = ResolveStatus(context.Channel);
var message = status switch
{
ChannelHealthStatus.Healthy => "Slack channel configuration validated.",
ChannelHealthStatus.Degraded => "Slack channel is disabled; enable it to resume deliveries.",
ChannelHealthStatus.Unhealthy => "Slack channel is missing a configured destination (target).",
_ => "Slack channel diagnostics completed."
};
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
}
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
{
if (!HasConfiguredTarget(channel))
{
return ChannelHealthStatus.Unhealthy;
}
if (!channel.Enabled)
{
return ChannelHealthStatus.Degraded;
}
return ChannelHealthStatus.Healthy;
}
private static bool HasConfiguredTarget(NotifyChannel channel)
=> !string.IsNullOrWhiteSpace(channel.Config.Target);
}

View File

@@ -1,18 +1,18 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Slack;
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Slack;
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly string DefaultTitle = "Stella Ops Notify Preview";
@@ -54,9 +54,9 @@ public sealed class SlackChannelTestProvider : INotifyChannelTestProvider
{
new
{
type = "section",
text = new { type = "mrkdwn", text = $"*{title}*\n{bodyText}" }
},
type = "section",
text = new { type = "mrkdwn", text = $"*{title}*\n{bodyText}" }
},
new
{
type = "context",

View File

@@ -1,77 +1,77 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Slack;
/// <summary>
/// Builds metadata for Slack previews and health diagnostics while redacting sensitive material.
/// </summary>
internal static class SlackMetadataBuilder
{
private static readonly string[] RequiredScopes = { "chat:write", "chat:write.public" };
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
=> CreateBuilder(context).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
IReadOnlyDictionary<string, string>? properties,
string secretRef)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("slack.channel", target)
.Add("slack.scopes.required", string.Join(',', RequiredScopes))
.AddTimestamp("slack.preview.generatedAt", timestamp)
.AddSecretRefHash("slack.secretRef.hash", secretRef)
.AddConfigTarget("slack.config.target", channel.Config.Target, target)
.AddConfigProperties("slack.config.", properties, (key, value) => RedactSlackValue(builder, key, value));
return builder;
}
private static string RedactSlackValue(ConnectorMetadataBuilder builder, string key, string value)
{
if (LooksLikeSlackToken(value))
{
return ConnectorValueRedactor.RedactToken(value);
}
return builder.Redact(key, value);
}
private static bool LooksLikeSlackToken(string value)
{
var trimmed = value.Trim();
if (trimmed.Length < 6)
{
return false;
}
return trimmed.StartsWith("xox", StringComparison.OrdinalIgnoreCase);
}
}
using System;
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Slack;
/// <summary>
/// Builds metadata for Slack previews and health diagnostics while redacting sensitive material.
/// </summary>
internal static class SlackMetadataBuilder
{
private static readonly string[] RequiredScopes = { "chat:write", "chat:write.public" };
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
=> CreateBuilder(context).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
IReadOnlyDictionary<string, string>? properties,
string secretRef)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("slack.channel", target)
.Add("slack.scopes.required", string.Join(',', RequiredScopes))
.AddTimestamp("slack.preview.generatedAt", timestamp)
.AddSecretRefHash("slack.secretRef.hash", secretRef)
.AddConfigTarget("slack.config.target", channel.Config.Target, target)
.AddConfigProperties("slack.config.", properties, (key, value) => RedactSlackValue(builder, key, value));
return builder;
}
private static string RedactSlackValue(ConnectorMetadataBuilder builder, string key, string value)
{
if (LooksLikeSlackToken(value))
{
return ConnectorValueRedactor.RedactToken(value);
}
return builder.Redact(key, value);
}
private static bool LooksLikeSlackToken(string value)
{
var trimmed = value.Trim();
if (trimmed.Length < 6)
{
return false;
}
return trimmed.StartsWith("xox", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,57 +1,57 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Teams;
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
public sealed class TeamsChannelHealthProvider : INotifyChannelHealthProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var builder = TeamsMetadataBuilder.CreateBuilder(context)
.Add("teams.channel.enabled", context.Channel.Enabled ? "true" : "false")
.Add("teams.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
var metadata = builder.Build();
var status = ResolveStatus(context.Channel);
var message = status switch
{
ChannelHealthStatus.Healthy => "Teams channel configuration validated.",
ChannelHealthStatus.Degraded => "Teams channel is disabled; enable it to resume deliveries.",
ChannelHealthStatus.Unhealthy => "Teams channel is missing a target/endpoint configuration.",
_ => "Teams channel diagnostics completed."
};
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
}
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
{
if (!HasConfiguredTarget(channel))
{
return ChannelHealthStatus.Unhealthy;
}
if (!channel.Enabled)
{
return ChannelHealthStatus.Degraded;
}
return ChannelHealthStatus.Healthy;
}
private static bool HasConfiguredTarget(NotifyChannel channel)
=> !string.IsNullOrWhiteSpace(channel.Config.Endpoint) ||
!string.IsNullOrWhiteSpace(channel.Config.Target);
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Teams;
[ServiceBinding(typeof(INotifyChannelHealthProvider), ServiceLifetime.Singleton)]
public sealed class TeamsChannelHealthProvider : INotifyChannelHealthProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
public Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
cancellationToken.ThrowIfCancellationRequested();
var builder = TeamsMetadataBuilder.CreateBuilder(context)
.Add("teams.channel.enabled", context.Channel.Enabled ? "true" : "false")
.Add("teams.validation.targetPresent", HasConfiguredTarget(context.Channel) ? "true" : "false");
var metadata = builder.Build();
var status = ResolveStatus(context.Channel);
var message = status switch
{
ChannelHealthStatus.Healthy => "Teams channel configuration validated.",
ChannelHealthStatus.Degraded => "Teams channel is disabled; enable it to resume deliveries.",
ChannelHealthStatus.Unhealthy => "Teams channel is missing a target/endpoint configuration.",
_ => "Teams channel diagnostics completed."
};
return Task.FromResult(new ChannelHealthResult(status, message, metadata));
}
private static ChannelHealthStatus ResolveStatus(NotifyChannel channel)
{
if (!HasConfiguredTarget(channel))
{
return ChannelHealthStatus.Unhealthy;
}
if (!channel.Enabled)
{
return ChannelHealthStatus.Degraded;
}
return ChannelHealthStatus.Healthy;
}
private static bool HasConfiguredTarget(NotifyChannel channel)
=> !string.IsNullOrWhiteSpace(channel.Config.Endpoint) ||
!string.IsNullOrWhiteSpace(channel.Config.Target);
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -49,13 +49,13 @@ public sealed class TeamsChannelTestProvider : INotifyChannelTestProvider
new
{
contentType = "application/vnd.microsoft.card.adaptive",
content = card
}
}
};
var body = JsonSerializer.Serialize(payload, JsonOptions);
content = card
}
}
};
var body = JsonSerializer.Serialize(payload, JsonOptions);
var preview = NotifyDeliveryRendered.Create(
NotifyChannelType.Teams,
NotifyDeliveryFormat.Teams,

View File

@@ -1,89 +1,89 @@
using System;
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Teams;
/// <summary>
/// Builds metadata for Teams previews and health diagnostics while redacting sensitive material.
/// </summary>
internal static class TeamsMetadataBuilder
{
internal const string CardVersion = "1.5";
private const int SecretHashLengthBytes = 8;
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context, string fallbackText)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
fallbackText: fallbackText,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef,
endpoint: context.Channel.Config.Endpoint);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
fallbackText: null,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef,
endpoint: context.Channel.Config.Endpoint);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context, string fallbackText)
=> CreateBuilder(context, fallbackText).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
string? fallbackText,
IReadOnlyDictionary<string, string>? properties,
string secretRef,
string? endpoint)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("teams.webhook", target)
.AddTimestamp("teams.preview.generatedAt", timestamp)
.Add("teams.card.version", CardVersion)
.AddSecretRefHash("teams.secretRef.hash", secretRef, SecretHashLengthBytes)
.AddConfigTarget("teams.config.target", channel.Config.Target, target)
.AddConfigEndpoint("teams.config.endpoint", endpoint)
.AddConfigProperties("teams.config.", properties, (key, value) => RedactTeamsValue(builder, key, value));
if (!string.IsNullOrWhiteSpace(fallbackText))
{
builder.Add("teams.fallbackText", fallbackText!);
}
return builder;
}
private static string RedactTeamsValue(ConnectorMetadataBuilder builder, string key, string value)
{
if (ConnectorValueRedactor.IsSensitiveKey(key, builder.SensitiveFragments))
{
return ConnectorValueRedactor.RedactSecret(value);
}
var trimmed = value.Trim();
if (LooksLikeGuid(trimmed))
{
return ConnectorValueRedactor.RedactToken(trimmed, prefixLength: 8, suffixLength: 4);
}
return trimmed;
}
private static bool LooksLikeGuid(string value)
=> value.Length >= 32 && Guid.TryParse(value, out _);
}
using System;
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Teams;
/// <summary>
/// Builds metadata for Teams previews and health diagnostics while redacting sensitive material.
/// </summary>
internal static class TeamsMetadataBuilder
{
internal const string CardVersion = "1.5";
private const int SecretHashLengthBytes = 8;
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context, string fallbackText)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
fallbackText: fallbackText,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef,
endpoint: context.Channel.Config.Endpoint);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
fallbackText: null,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef,
endpoint: context.Channel.Config.Endpoint);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context, string fallbackText)
=> CreateBuilder(context, fallbackText).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
string? fallbackText,
IReadOnlyDictionary<string, string>? properties,
string secretRef,
string? endpoint)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("teams.webhook", target)
.AddTimestamp("teams.preview.generatedAt", timestamp)
.Add("teams.card.version", CardVersion)
.AddSecretRefHash("teams.secretRef.hash", secretRef, SecretHashLengthBytes)
.AddConfigTarget("teams.config.target", channel.Config.Target, target)
.AddConfigEndpoint("teams.config.endpoint", endpoint)
.AddConfigProperties("teams.config.", properties, (key, value) => RedactTeamsValue(builder, key, value));
if (!string.IsNullOrWhiteSpace(fallbackText))
{
builder.Add("teams.fallbackText", fallbackText!);
}
return builder;
}
private static string RedactTeamsValue(ConnectorMetadataBuilder builder, string key, string value)
{
if (ConnectorValueRedactor.IsSensitiveKey(key, builder.SensitiveFragments))
{
return ConnectorValueRedactor.RedactSecret(value);
}
var trimmed = value.Trim();
if (LooksLikeGuid(trimmed))
{
return ConnectorValueRedactor.RedactToken(trimmed, prefixLength: 8, suffixLength: 4);
}
return trimmed;
}
private static bool LooksLikeGuid(string value)
=> value.Length >= 32 && Guid.TryParse(value, out _);
}

View File

@@ -1,53 +1,53 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Webhook;
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
public sealed class WebhookChannelTestProvider : INotifyChannelTestProvider
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var title = context.Request.Title ?? "Stella Ops Notify Preview";
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
var payload = new
{
title,
summary,
traceId = context.TraceId,
timestamp = context.Timestamp,
body = context.Request.Body,
metadata = context.Request.Metadata
};
var body = JsonSerializer.Serialize(payload, JsonOptions);
var preview = NotifyDeliveryRendered.Create(
NotifyChannelType.Webhook,
NotifyDeliveryFormat.Webhook,
context.Target,
title,
body,
summary,
context.Request.TextBody ?? summary,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(body),
context.Request.Attachments);
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Webhook;
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
public sealed class WebhookChannelTestProvider : INotifyChannelTestProvider
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var title = context.Request.Title ?? "Stella Ops Notify Preview";
var summary = context.Request.Summary ?? $"Preview generated at {context.Timestamp:O}.";
var payload = new
{
title,
summary,
traceId = context.TraceId,
timestamp = context.Timestamp,
body = context.Request.Body,
metadata = context.Request.Metadata
};
var body = JsonSerializer.Serialize(payload, JsonOptions);
var preview = NotifyDeliveryRendered.Create(
NotifyChannelType.Webhook,
NotifyDeliveryFormat.Webhook,
context.Target,
title,
body,
summary,
context.Request.TextBody ?? summary,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(body),
context.Request.Attachments);
var metadata = WebhookMetadataBuilder.Build(context);
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));

View File

@@ -1,53 +1,53 @@
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Webhook;
/// <summary>
/// Builds metadata for Webhook previews and health diagnostics.
/// </summary>
internal static class WebhookMetadataBuilder
{
private const int SecretHashLengthBytes = 8;
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
=> CreateBuilder(context).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
IReadOnlyDictionary<string, string>? properties,
string secretRef)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("webhook.endpoint", target)
.AddTimestamp("webhook.preview.generatedAt", timestamp)
.AddSecretRefHash("webhook.secretRef.hash", secretRef, SecretHashLengthBytes)
.AddConfigProperties("webhook.config.", properties);
return builder;
}
}
using System.Collections.Generic;
using StellaOps.Notify.Connectors.Shared;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Connectors.Webhook;
/// <summary>
/// Builds metadata for Webhook previews and health diagnostics.
/// </summary>
internal static class WebhookMetadataBuilder
{
private const int SecretHashLengthBytes = 8;
public static ConnectorMetadataBuilder CreateBuilder(ChannelTestPreviewContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static ConnectorMetadataBuilder CreateBuilder(ChannelHealthContext context)
=> CreateBaseBuilder(
channel: context.Channel,
target: context.Target,
timestamp: context.Timestamp,
properties: context.Channel.Config.Properties,
secretRef: context.Channel.Config.SecretRef);
public static IReadOnlyDictionary<string, string> Build(ChannelTestPreviewContext context)
=> CreateBuilder(context).Build();
public static IReadOnlyDictionary<string, string> Build(ChannelHealthContext context)
=> CreateBuilder(context).Build();
private static ConnectorMetadataBuilder CreateBaseBuilder(
NotifyChannel channel,
string target,
DateTimeOffset timestamp,
IReadOnlyDictionary<string, string>? properties,
string secretRef)
{
var builder = new ConnectorMetadataBuilder();
builder.AddTarget("webhook.endpoint", target)
.AddTimestamp("webhook.preview.generatedAt", timestamp)
.AddSecretRefHash("webhook.secretRef.hash", secretRef, SecretHashLengthBytes)
.AddConfigProperties("webhook.config.", properties);
return builder;
}
}

View File

@@ -1,51 +1,51 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Contract implemented by channel plug-ins to provide health diagnostics.
/// </summary>
public interface INotifyChannelHealthProvider
{
/// <summary>
/// Channel type supported by the provider.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Executes a health check for the supplied channel.
/// </summary>
Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken);
}
/// <summary>
/// Immutable context describing a channel health request.
/// </summary>
public sealed record ChannelHealthContext(
string TenantId,
NotifyChannel Channel,
string Target,
DateTimeOffset Timestamp,
string TraceId);
/// <summary>
/// Result returned by channel plug-ins when reporting health diagnostics.
/// </summary>
public sealed record ChannelHealthResult(
ChannelHealthStatus Status,
string? Message,
IReadOnlyDictionary<string, string> Metadata);
/// <summary>
/// Supported channel health states.
/// </summary>
public enum ChannelHealthStatus
{
Healthy,
Degraded,
Unhealthy
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Contract implemented by channel plug-ins to provide health diagnostics.
/// </summary>
public interface INotifyChannelHealthProvider
{
/// <summary>
/// Channel type supported by the provider.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Executes a health check for the supplied channel.
/// </summary>
Task<ChannelHealthResult> CheckAsync(ChannelHealthContext context, CancellationToken cancellationToken);
}
/// <summary>
/// Immutable context describing a channel health request.
/// </summary>
public sealed record ChannelHealthContext(
string TenantId,
NotifyChannel Channel,
string Target,
DateTimeOffset Timestamp,
string TraceId);
/// <summary>
/// Result returned by channel plug-ins when reporting health diagnostics.
/// </summary>
public sealed record ChannelHealthResult(
ChannelHealthStatus Status,
string? Message,
IReadOnlyDictionary<string, string> Metadata);
/// <summary>
/// Supported channel health states.
/// </summary>
public enum ChannelHealthStatus
{
Healthy,
Degraded,
Unhealthy
}

View File

@@ -1,84 +1,84 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Contract implemented by Notify channel plug-ins to generate channel-specific test preview payloads.
/// </summary>
public interface INotifyChannelTestProvider
{
/// <summary>
/// Channel type supported by the provider.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Builds a channel-specific preview for a test-send request.
/// </summary>
Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken);
}
/// <summary>
/// Sanitised request payload passed to channel plug-ins when building a preview.
/// </summary>
public sealed record ChannelTestPreviewRequest(
string? TargetOverride,
string? TemplateId,
string? Title,
string? Summary,
string? Body,
string? TextBody,
string? Locale,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<string> Attachments);
/// <summary>
/// Immutable context describing the channel and request for a test preview.
/// </summary>
public sealed record ChannelTestPreviewContext(
string TenantId,
NotifyChannel Channel,
string Target,
ChannelTestPreviewRequest Request,
DateTimeOffset Timestamp,
string TraceId);
/// <summary>
/// Result returned by channel plug-ins for test preview generation.
/// </summary>
public sealed record ChannelTestPreviewResult(
NotifyDeliveryRendered Preview,
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Exception thrown by plug-ins when preview input is invalid.
/// </summary>
public sealed class ChannelTestPreviewException : Exception
{
public ChannelTestPreviewException(string message)
: base(message)
{
}
}
/// <summary>
/// Shared helpers for channel preview generation.
/// </summary>
public static class ChannelTestPreviewUtilities
{
/// <summary>
/// Computes a lowercase hex SHA-256 body hash for preview payloads.
/// </summary>
public static string ComputeBodyHash(string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Contract implemented by Notify channel plug-ins to generate channel-specific test preview payloads.
/// </summary>
public interface INotifyChannelTestProvider
{
/// <summary>
/// Channel type supported by the provider.
/// </summary>
NotifyChannelType ChannelType { get; }
/// <summary>
/// Builds a channel-specific preview for a test-send request.
/// </summary>
Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken);
}
/// <summary>
/// Sanitised request payload passed to channel plug-ins when building a preview.
/// </summary>
public sealed record ChannelTestPreviewRequest(
string? TargetOverride,
string? TemplateId,
string? Title,
string? Summary,
string? Body,
string? TextBody,
string? Locale,
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyList<string> Attachments);
/// <summary>
/// Immutable context describing the channel and request for a test preview.
/// </summary>
public sealed record ChannelTestPreviewContext(
string TenantId,
NotifyChannel Channel,
string Target,
ChannelTestPreviewRequest Request,
DateTimeOffset Timestamp,
string TraceId);
/// <summary>
/// Result returned by channel plug-ins for test preview generation.
/// </summary>
public sealed record ChannelTestPreviewResult(
NotifyDeliveryRendered Preview,
IReadOnlyDictionary<string, string>? Metadata);
/// <summary>
/// Exception thrown by plug-ins when preview input is invalid.
/// </summary>
public sealed class ChannelTestPreviewException : Exception
{
public ChannelTestPreviewException(string message)
: base(message)
{
}
}
/// <summary>
/// Shared helpers for channel preview generation.
/// </summary>
public static class ChannelTestPreviewUtilities
{
/// <summary>
/// Computes a lowercase hex SHA-256 body hash for preview payloads.
/// </summary>
public static string ComputeBodyHash(string body)
{
var bytes = Encoding.UTF8.GetBytes(body);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,28 +1,28 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Evaluates Notify rules against platform events.
/// </summary>
public interface INotifyRuleEvaluator
{
/// <summary>
/// Evaluates a single rule against an event and returns the match outcome.
/// </summary>
NotifyRuleEvaluationOutcome Evaluate(
NotifyRule rule,
NotifyEvent @event,
DateTimeOffset? evaluationTimestamp = null);
/// <summary>
/// Evaluates a collection of rules against an event.
/// </summary>
ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
IEnumerable<NotifyRule> rules,
NotifyEvent @event,
DateTimeOffset? evaluationTimestamp = null);
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Evaluates Notify rules against platform events.
/// </summary>
public interface INotifyRuleEvaluator
{
/// <summary>
/// Evaluates a single rule against an event and returns the match outcome.
/// </summary>
NotifyRuleEvaluationOutcome Evaluate(
NotifyRule rule,
NotifyEvent @event,
DateTimeOffset? evaluationTimestamp = null);
/// <summary>
/// Evaluates a collection of rules against an event.
/// </summary>
ImmutableArray<NotifyRuleEvaluationOutcome> Evaluate(
IEnumerable<NotifyRule> rules,
NotifyEvent @event,
DateTimeOffset? evaluationTimestamp = null);
}

View File

@@ -1,44 +1,44 @@
using System;
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Outcome produced when evaluating a notify rule against an event.
/// </summary>
public sealed record NotifyRuleEvaluationOutcome
{
private NotifyRuleEvaluationOutcome(
NotifyRule rule,
bool isMatch,
ImmutableArray<NotifyRuleAction> actions,
DateTimeOffset? matchedAt,
string? reason)
{
Rule = rule ?? throw new ArgumentNullException(nameof(rule));
IsMatch = isMatch;
Actions = actions;
MatchedAt = matchedAt;
Reason = reason;
}
public NotifyRule Rule { get; }
public bool IsMatch { get; }
public ImmutableArray<NotifyRuleAction> Actions { get; }
public DateTimeOffset? MatchedAt { get; }
public string? Reason { get; }
public static NotifyRuleEvaluationOutcome NotMatched(NotifyRule rule, string reason)
=> new(rule, false, ImmutableArray<NotifyRuleAction>.Empty, null, reason);
public static NotifyRuleEvaluationOutcome Matched(
NotifyRule rule,
ImmutableArray<NotifyRuleAction> actions,
DateTimeOffset matchedAt)
=> new(rule, true, actions, matchedAt, null);
}
using System;
using System.Collections.Immutable;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Engine;
/// <summary>
/// Outcome produced when evaluating a notify rule against an event.
/// </summary>
public sealed record NotifyRuleEvaluationOutcome
{
private NotifyRuleEvaluationOutcome(
NotifyRule rule,
bool isMatch,
ImmutableArray<NotifyRuleAction> actions,
DateTimeOffset? matchedAt,
string? reason)
{
Rule = rule ?? throw new ArgumentNullException(nameof(rule));
IsMatch = isMatch;
Actions = actions;
MatchedAt = matchedAt;
Reason = reason;
}
public NotifyRule Rule { get; }
public bool IsMatch { get; }
public ImmutableArray<NotifyRuleAction> Actions { get; }
public DateTimeOffset? MatchedAt { get; }
public string? Reason { get; }
public static NotifyRuleEvaluationOutcome NotMatched(NotifyRule rule, string reason)
=> new(rule, false, ImmutableArray<NotifyRuleAction>.Empty, null, reason);
public static NotifyRuleEvaluationOutcome Matched(
NotifyRule rule,
ImmutableArray<NotifyRuleAction> actions,
DateTimeOffset matchedAt)
=> new(rule, true, actions, matchedAt, null);
}

View File

@@ -1,28 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml;
namespace StellaOps.Notify.Models;
internal sealed class Iso8601DurationConverter : JsonConverter<TimeSpan>
{
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.String)
{
var value = reader.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
return XmlConvert.ToTimeSpan(value);
}
}
throw new JsonException("Expected ISO 8601 duration string.");
}
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
{
var normalized = XmlConvert.ToString(value);
writer.WriteStringValue(normalized);
}
}
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml;
namespace StellaOps.Notify.Models;
internal sealed class Iso8601DurationConverter : JsonConverter<TimeSpan>
{
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.String)
{
var value = reader.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
return XmlConvert.ToTimeSpan(value);
}
}
throw new JsonException("Expected ISO 8601 duration string.");
}
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
{
var normalized = XmlConvert.ToString(value);
writer.WriteStringValue(normalized);
}
}

View File

@@ -1,235 +1,235 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Configured delivery channel (Slack workspace, Teams webhook, SMTP profile, etc.).
/// </summary>
public sealed record NotifyChannel
{
[JsonConstructor]
public NotifyChannel(
string channelId,
string tenantId,
string name,
NotifyChannelType type,
NotifyChannelConfig config,
string? displayName = null,
string? description = null,
bool enabled = true,
ImmutableDictionary<string, string>? labels = null,
ImmutableDictionary<string, string>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
SchemaVersion = NotifySchemaVersions.EnsureChannel(schemaVersion);
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
Type = type;
Config = config ?? throw new ArgumentNullException(nameof(config));
DisplayName = NotifyValidation.TrimToNull(displayName);
Description = NotifyValidation.TrimToNull(description);
Enabled = enabled;
Labels = NotifyValidation.NormalizeStringDictionary(labels);
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 NotifyChannel Create(
string channelId,
string tenantId,
string name,
NotifyChannelType type,
NotifyChannelConfig config,
string? displayName = null,
string? description = null,
bool enabled = true,
IEnumerable<KeyValuePair<string, string>>? labels = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
return new NotifyChannel(
channelId,
tenantId,
name,
type,
config,
displayName,
description,
enabled,
ToImmutableDictionary(labels),
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt,
schemaVersion);
}
public string SchemaVersion { get; }
public string ChannelId { get; }
public string TenantId { get; }
public string Name { get; }
public NotifyChannelType Type { get; }
public NotifyChannelConfig Config { get; }
public string? DisplayName { get; }
public string? Description { get; }
public bool Enabled { get; }
public ImmutableDictionary<string, string> Labels { 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>
/// Channel configuration payload (secret reference, destination coordinates, connector-specific metadata).
/// </summary>
public sealed record NotifyChannelConfig
{
[JsonConstructor]
public NotifyChannelConfig(
string secretRef,
string? target = null,
string? endpoint = null,
ImmutableDictionary<string, string>? properties = null,
NotifyChannelLimits? limits = null)
{
SecretRef = NotifyValidation.EnsureNotNullOrWhiteSpace(secretRef, nameof(secretRef));
Target = NotifyValidation.TrimToNull(target);
Endpoint = NotifyValidation.TrimToNull(endpoint);
Properties = NotifyValidation.NormalizeStringDictionary(properties);
Limits = limits;
}
public static NotifyChannelConfig Create(
string secretRef,
string? target = null,
string? endpoint = null,
IEnumerable<KeyValuePair<string, string>>? properties = null,
NotifyChannelLimits? limits = null)
{
return new NotifyChannelConfig(
secretRef,
target,
endpoint,
ToImmutableDictionary(properties),
limits);
}
public string SecretRef { get; }
public string? Target { get; }
public string? Endpoint { get; }
public ImmutableDictionary<string, string> Properties { get; }
public NotifyChannelLimits? Limits { 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>
/// Optional per-channel limits that influence worker behaviour.
/// </summary>
public sealed record NotifyChannelLimits
{
[JsonConstructor]
public NotifyChannelLimits(
int? concurrency = null,
int? requestsPerMinute = null,
TimeSpan? timeout = null,
int? maxBatchSize = null)
{
if (concurrency is < 1)
{
throw new ArgumentOutOfRangeException(nameof(concurrency), "Concurrency must be positive when specified.");
}
if (requestsPerMinute is < 1)
{
throw new ArgumentOutOfRangeException(nameof(requestsPerMinute), "Requests per minute must be positive when specified.");
}
if (maxBatchSize is < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxBatchSize), "Max batch size must be positive when specified.");
}
Concurrency = concurrency;
RequestsPerMinute = requestsPerMinute;
Timeout = timeout is { Ticks: > 0 } ? timeout : null;
MaxBatchSize = maxBatchSize;
}
public int? Concurrency { get; }
public int? RequestsPerMinute { get; }
public TimeSpan? Timeout { get; }
public int? MaxBatchSize { get; }
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Configured delivery channel (Slack workspace, Teams webhook, SMTP profile, etc.).
/// </summary>
public sealed record NotifyChannel
{
[JsonConstructor]
public NotifyChannel(
string channelId,
string tenantId,
string name,
NotifyChannelType type,
NotifyChannelConfig config,
string? displayName = null,
string? description = null,
bool enabled = true,
ImmutableDictionary<string, string>? labels = null,
ImmutableDictionary<string, string>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
SchemaVersion = NotifySchemaVersions.EnsureChannel(schemaVersion);
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
Type = type;
Config = config ?? throw new ArgumentNullException(nameof(config));
DisplayName = NotifyValidation.TrimToNull(displayName);
Description = NotifyValidation.TrimToNull(description);
Enabled = enabled;
Labels = NotifyValidation.NormalizeStringDictionary(labels);
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 NotifyChannel Create(
string channelId,
string tenantId,
string name,
NotifyChannelType type,
NotifyChannelConfig config,
string? displayName = null,
string? description = null,
bool enabled = true,
IEnumerable<KeyValuePair<string, string>>? labels = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
return new NotifyChannel(
channelId,
tenantId,
name,
type,
config,
displayName,
description,
enabled,
ToImmutableDictionary(labels),
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt,
schemaVersion);
}
public string SchemaVersion { get; }
public string ChannelId { get; }
public string TenantId { get; }
public string Name { get; }
public NotifyChannelType Type { get; }
public NotifyChannelConfig Config { get; }
public string? DisplayName { get; }
public string? Description { get; }
public bool Enabled { get; }
public ImmutableDictionary<string, string> Labels { 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>
/// Channel configuration payload (secret reference, destination coordinates, connector-specific metadata).
/// </summary>
public sealed record NotifyChannelConfig
{
[JsonConstructor]
public NotifyChannelConfig(
string secretRef,
string? target = null,
string? endpoint = null,
ImmutableDictionary<string, string>? properties = null,
NotifyChannelLimits? limits = null)
{
SecretRef = NotifyValidation.EnsureNotNullOrWhiteSpace(secretRef, nameof(secretRef));
Target = NotifyValidation.TrimToNull(target);
Endpoint = NotifyValidation.TrimToNull(endpoint);
Properties = NotifyValidation.NormalizeStringDictionary(properties);
Limits = limits;
}
public static NotifyChannelConfig Create(
string secretRef,
string? target = null,
string? endpoint = null,
IEnumerable<KeyValuePair<string, string>>? properties = null,
NotifyChannelLimits? limits = null)
{
return new NotifyChannelConfig(
secretRef,
target,
endpoint,
ToImmutableDictionary(properties),
limits);
}
public string SecretRef { get; }
public string? Target { get; }
public string? Endpoint { get; }
public ImmutableDictionary<string, string> Properties { get; }
public NotifyChannelLimits? Limits { 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>
/// Optional per-channel limits that influence worker behaviour.
/// </summary>
public sealed record NotifyChannelLimits
{
[JsonConstructor]
public NotifyChannelLimits(
int? concurrency = null,
int? requestsPerMinute = null,
TimeSpan? timeout = null,
int? maxBatchSize = null)
{
if (concurrency is < 1)
{
throw new ArgumentOutOfRangeException(nameof(concurrency), "Concurrency must be positive when specified.");
}
if (requestsPerMinute is < 1)
{
throw new ArgumentOutOfRangeException(nameof(requestsPerMinute), "Requests per minute must be positive when specified.");
}
if (maxBatchSize is < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxBatchSize), "Max batch size must be positive when specified.");
}
Concurrency = concurrency;
RequestsPerMinute = requestsPerMinute;
Timeout = timeout is { Ticks: > 0 } ? timeout : null;
MaxBatchSize = maxBatchSize;
}
public int? Concurrency { get; }
public int? RequestsPerMinute { get; }
public TimeSpan? Timeout { get; }
public int? MaxBatchSize { get; }
}

View File

@@ -1,252 +1,252 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Delivery ledger entry capturing render output, attempts, and status transitions.
/// </summary>
public sealed record NotifyDelivery
{
[JsonConstructor]
public NotifyDelivery(
string deliveryId,
string tenantId,
string ruleId,
string actionId,
Guid eventId,
string kind,
NotifyDeliveryStatus status,
string? statusReason = null,
NotifyDeliveryRendered? rendered = null,
ImmutableArray<NotifyDeliveryAttempt> attempts = default,
ImmutableDictionary<string, string>? metadata = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? sentAt = null,
DateTimeOffset? completedAt = null)
{
DeliveryId = NotifyValidation.EnsureNotNullOrWhiteSpace(deliveryId, nameof(deliveryId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
EventId = eventId;
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
Status = status;
StatusReason = NotifyValidation.TrimToNull(statusReason);
Rendered = rendered;
Attempts = NormalizeAttempts(attempts);
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
SentAt = NotifyValidation.EnsureUtc(sentAt);
CompletedAt = NotifyValidation.EnsureUtc(completedAt);
}
public static NotifyDelivery Create(
string deliveryId,
string tenantId,
string ruleId,
string actionId,
Guid eventId,
string kind,
NotifyDeliveryStatus status,
string? statusReason = null,
NotifyDeliveryRendered? rendered = null,
IEnumerable<NotifyDeliveryAttempt>? attempts = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? sentAt = null,
DateTimeOffset? completedAt = null)
{
return new NotifyDelivery(
deliveryId,
tenantId,
ruleId,
actionId,
eventId,
kind,
status,
statusReason,
rendered,
ToImmutableArray(attempts),
ToImmutableDictionary(metadata),
createdAt,
sentAt,
completedAt);
}
public string DeliveryId { get; }
public string TenantId { get; }
public string RuleId { get; }
public string ActionId { get; }
public Guid EventId { get; }
public string Kind { get; }
public NotifyDeliveryStatus Status { get; }
public string? StatusReason { get; }
public NotifyDeliveryRendered? Rendered { get; }
public ImmutableArray<NotifyDeliveryAttempt> Attempts { get; }
public ImmutableDictionary<string, string> Metadata { get; }
public DateTimeOffset CreatedAt { get; }
public DateTimeOffset? SentAt { get; }
public DateTimeOffset? CompletedAt { get; }
private static ImmutableArray<NotifyDeliveryAttempt> NormalizeAttempts(ImmutableArray<NotifyDeliveryAttempt> attempts)
{
var source = attempts.IsDefault ? Array.Empty<NotifyDeliveryAttempt>() : attempts.AsEnumerable();
return source
.Where(static attempt => attempt is not null)
.OrderBy(static attempt => attempt.Timestamp)
.ToImmutableArray();
}
private static ImmutableArray<NotifyDeliveryAttempt> ToImmutableArray(IEnumerable<NotifyDeliveryAttempt>? attempts)
{
if (attempts is null)
{
return ImmutableArray<NotifyDeliveryAttempt>.Empty;
}
return attempts.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>
/// Individual delivery attempt outcome.
/// </summary>
public sealed record NotifyDeliveryAttempt
{
[JsonConstructor]
public NotifyDeliveryAttempt(
DateTimeOffset timestamp,
NotifyDeliveryAttemptStatus status,
int? statusCode = null,
string? reason = null)
{
Timestamp = NotifyValidation.EnsureUtc(timestamp);
Status = status;
if (statusCode is < 0)
{
throw new ArgumentOutOfRangeException(nameof(statusCode), "Status code must be positive when specified.");
}
StatusCode = statusCode;
Reason = NotifyValidation.TrimToNull(reason);
}
public DateTimeOffset Timestamp { get; }
public NotifyDeliveryAttemptStatus Status { get; }
public int? StatusCode { get; }
public string? Reason { get; }
}
/// <summary>
/// Rendered payload snapshot for audit purposes (redacted as needed).
/// </summary>
public sealed record NotifyDeliveryRendered
{
[JsonConstructor]
public NotifyDeliveryRendered(
NotifyChannelType channelType,
NotifyDeliveryFormat format,
string target,
string title,
string body,
string? summary = null,
string? textBody = null,
string? locale = null,
string? bodyHash = null,
ImmutableArray<string> attachments = default)
{
ChannelType = channelType;
Format = format;
Target = NotifyValidation.EnsureNotNullOrWhiteSpace(target, nameof(target));
Title = NotifyValidation.EnsureNotNullOrWhiteSpace(title, nameof(title));
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
Summary = NotifyValidation.TrimToNull(summary);
TextBody = NotifyValidation.TrimToNull(textBody);
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
BodyHash = NotifyValidation.TrimToNull(bodyHash);
Attachments = NotifyValidation.NormalizeStringSet(attachments.IsDefault ? Array.Empty<string>() : attachments.AsEnumerable());
}
public static NotifyDeliveryRendered Create(
NotifyChannelType channelType,
NotifyDeliveryFormat format,
string target,
string title,
string body,
string? summary = null,
string? textBody = null,
string? locale = null,
string? bodyHash = null,
IEnumerable<string>? attachments = null)
{
return new NotifyDeliveryRendered(
channelType,
format,
target,
title,
body,
summary,
textBody,
locale,
bodyHash,
attachments is null ? ImmutableArray<string>.Empty : attachments.ToImmutableArray());
}
public NotifyChannelType ChannelType { get; }
public NotifyDeliveryFormat Format { get; }
public string Target { get; }
public string Title { get; }
public string Body { get; }
public string? Summary { get; }
public string? TextBody { get; }
public string? Locale { get; }
public string? BodyHash { get; }
public ImmutableArray<string> Attachments { get; }
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Delivery ledger entry capturing render output, attempts, and status transitions.
/// </summary>
public sealed record NotifyDelivery
{
[JsonConstructor]
public NotifyDelivery(
string deliveryId,
string tenantId,
string ruleId,
string actionId,
Guid eventId,
string kind,
NotifyDeliveryStatus status,
string? statusReason = null,
NotifyDeliveryRendered? rendered = null,
ImmutableArray<NotifyDeliveryAttempt> attempts = default,
ImmutableDictionary<string, string>? metadata = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? sentAt = null,
DateTimeOffset? completedAt = null)
{
DeliveryId = NotifyValidation.EnsureNotNullOrWhiteSpace(deliveryId, nameof(deliveryId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
EventId = eventId;
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
Status = status;
StatusReason = NotifyValidation.TrimToNull(statusReason);
Rendered = rendered;
Attempts = NormalizeAttempts(attempts);
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
CreatedAt = NotifyValidation.EnsureUtc(createdAt ?? DateTimeOffset.UtcNow);
SentAt = NotifyValidation.EnsureUtc(sentAt);
CompletedAt = NotifyValidation.EnsureUtc(completedAt);
}
public static NotifyDelivery Create(
string deliveryId,
string tenantId,
string ruleId,
string actionId,
Guid eventId,
string kind,
NotifyDeliveryStatus status,
string? statusReason = null,
NotifyDeliveryRendered? rendered = null,
IEnumerable<NotifyDeliveryAttempt>? attempts = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
DateTimeOffset? createdAt = null,
DateTimeOffset? sentAt = null,
DateTimeOffset? completedAt = null)
{
return new NotifyDelivery(
deliveryId,
tenantId,
ruleId,
actionId,
eventId,
kind,
status,
statusReason,
rendered,
ToImmutableArray(attempts),
ToImmutableDictionary(metadata),
createdAt,
sentAt,
completedAt);
}
public string DeliveryId { get; }
public string TenantId { get; }
public string RuleId { get; }
public string ActionId { get; }
public Guid EventId { get; }
public string Kind { get; }
public NotifyDeliveryStatus Status { get; }
public string? StatusReason { get; }
public NotifyDeliveryRendered? Rendered { get; }
public ImmutableArray<NotifyDeliveryAttempt> Attempts { get; }
public ImmutableDictionary<string, string> Metadata { get; }
public DateTimeOffset CreatedAt { get; }
public DateTimeOffset? SentAt { get; }
public DateTimeOffset? CompletedAt { get; }
private static ImmutableArray<NotifyDeliveryAttempt> NormalizeAttempts(ImmutableArray<NotifyDeliveryAttempt> attempts)
{
var source = attempts.IsDefault ? Array.Empty<NotifyDeliveryAttempt>() : attempts.AsEnumerable();
return source
.Where(static attempt => attempt is not null)
.OrderBy(static attempt => attempt.Timestamp)
.ToImmutableArray();
}
private static ImmutableArray<NotifyDeliveryAttempt> ToImmutableArray(IEnumerable<NotifyDeliveryAttempt>? attempts)
{
if (attempts is null)
{
return ImmutableArray<NotifyDeliveryAttempt>.Empty;
}
return attempts.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>
/// Individual delivery attempt outcome.
/// </summary>
public sealed record NotifyDeliveryAttempt
{
[JsonConstructor]
public NotifyDeliveryAttempt(
DateTimeOffset timestamp,
NotifyDeliveryAttemptStatus status,
int? statusCode = null,
string? reason = null)
{
Timestamp = NotifyValidation.EnsureUtc(timestamp);
Status = status;
if (statusCode is < 0)
{
throw new ArgumentOutOfRangeException(nameof(statusCode), "Status code must be positive when specified.");
}
StatusCode = statusCode;
Reason = NotifyValidation.TrimToNull(reason);
}
public DateTimeOffset Timestamp { get; }
public NotifyDeliveryAttemptStatus Status { get; }
public int? StatusCode { get; }
public string? Reason { get; }
}
/// <summary>
/// Rendered payload snapshot for audit purposes (redacted as needed).
/// </summary>
public sealed record NotifyDeliveryRendered
{
[JsonConstructor]
public NotifyDeliveryRendered(
NotifyChannelType channelType,
NotifyDeliveryFormat format,
string target,
string title,
string body,
string? summary = null,
string? textBody = null,
string? locale = null,
string? bodyHash = null,
ImmutableArray<string> attachments = default)
{
ChannelType = channelType;
Format = format;
Target = NotifyValidation.EnsureNotNullOrWhiteSpace(target, nameof(target));
Title = NotifyValidation.EnsureNotNullOrWhiteSpace(title, nameof(title));
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
Summary = NotifyValidation.TrimToNull(summary);
TextBody = NotifyValidation.TrimToNull(textBody);
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
BodyHash = NotifyValidation.TrimToNull(bodyHash);
Attachments = NotifyValidation.NormalizeStringSet(attachments.IsDefault ? Array.Empty<string>() : attachments.AsEnumerable());
}
public static NotifyDeliveryRendered Create(
NotifyChannelType channelType,
NotifyDeliveryFormat format,
string target,
string title,
string body,
string? summary = null,
string? textBody = null,
string? locale = null,
string? bodyHash = null,
IEnumerable<string>? attachments = null)
{
return new NotifyDeliveryRendered(
channelType,
format,
target,
title,
body,
summary,
textBody,
locale,
bodyHash,
attachments is null ? ImmutableArray<string>.Empty : attachments.ToImmutableArray());
}
public NotifyChannelType ChannelType { get; }
public NotifyDeliveryFormat Format { get; }
public string Target { get; }
public string Title { get; }
public string Body { get; }
public string? Summary { get; }
public string? TextBody { get; }
public string? Locale { get; }
public string? BodyHash { get; }
public ImmutableArray<string> Attachments { get; }
}

View File

@@ -1,86 +1,86 @@
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Supported Notify channel types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyChannelType
{
Slack,
Teams,
Email,
Webhook,
Custom,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
InApp,
}
/// <summary>
/// Delivery lifecycle states tracked for audit and retries.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryStatus
{
Pending,
Queued,
Sending,
Delivered,
Sent,
Failed,
Throttled,
Digested,
Dropped,
}
/// <summary>
/// Individual attempt status recorded during delivery retries.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryAttemptStatus
{
Enqueued,
Sending,
Succeeded,
Success = Succeeded,
Failed,
Throttled,
Skipped,
}
/// <summary>
/// Rendering modes for templates to help connectors decide format handling.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyTemplateRenderMode
{
Markdown,
Html,
AdaptiveCard,
PlainText,
Json,
}
/// <summary>
/// Structured representation of rendered payload format.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryFormat
{
Markdown,
Html,
PlainText,
Slack,
Teams,
Email,
Webhook,
Json,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
}
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Supported Notify channel types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyChannelType
{
Slack,
Teams,
Email,
Webhook,
Custom,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
InApp,
}
/// <summary>
/// Delivery lifecycle states tracked for audit and retries.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryStatus
{
Pending,
Queued,
Sending,
Delivered,
Sent,
Failed,
Throttled,
Digested,
Dropped,
}
/// <summary>
/// Individual attempt status recorded during delivery retries.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryAttemptStatus
{
Enqueued,
Sending,
Succeeded,
Success = Succeeded,
Failed,
Throttled,
Skipped,
}
/// <summary>
/// Rendering modes for templates to help connectors decide format handling.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyTemplateRenderMode
{
Markdown,
Html,
AdaptiveCard,
PlainText,
Json,
}
/// <summary>
/// Structured representation of rendered payload format.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum NotifyDeliveryFormat
{
Markdown,
Html,
PlainText,
Slack,
Teams,
Email,
Webhook,
Json,
PagerDuty,
OpsGenie,
Cli,
InAppInbox,
}

View File

@@ -1,478 +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
}
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

@@ -1,168 +1,168 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Canonical platform event envelope consumed by Notify.
/// </summary>
public sealed record NotifyEvent
{
[JsonConstructor]
public NotifyEvent(
Guid eventId,
string kind,
string tenant,
DateTimeOffset ts,
JsonNode? payload,
NotifyEventScope? scope = null,
string? version = null,
string? actor = null,
ImmutableDictionary<string, string>? attributes = null)
{
EventId = eventId;
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
Tenant = NotifyValidation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
Ts = NotifyValidation.EnsureUtc(ts);
Payload = NotifyValidation.NormalizeJsonNode(payload);
Scope = scope;
Version = NotifyValidation.TrimToNull(version);
Actor = NotifyValidation.TrimToNull(actor);
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
}
public static NotifyEvent Create(
Guid eventId,
string kind,
string tenant,
DateTimeOffset ts,
JsonNode? payload,
NotifyEventScope? scope = null,
string? version = null,
string? actor = null,
IEnumerable<KeyValuePair<string, string>>? attributes = null)
{
return new NotifyEvent(
eventId,
kind,
tenant,
ts,
payload,
scope,
version,
actor,
ToImmutableDictionary(attributes));
}
public Guid EventId { get; }
public string Kind { get; }
public string Tenant { get; }
public DateTimeOffset Ts { get; }
public JsonNode? Payload { get; }
public NotifyEventScope? Scope { get; }
public string? Version { get; }
public string? Actor { get; }
public ImmutableDictionary<string, string> Attributes { 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>
/// Optional scope block describing where the event originated (namespace/repo/digest/etc.).
/// </summary>
public sealed record NotifyEventScope
{
[JsonConstructor]
public NotifyEventScope(
string? @namespace = null,
string? repo = null,
string? digest = null,
string? component = null,
string? image = null,
ImmutableDictionary<string, string>? labels = null,
ImmutableDictionary<string, string>? attributes = null)
{
Namespace = NotifyValidation.TrimToNull(@namespace);
Repo = NotifyValidation.TrimToNull(repo);
Digest = NotifyValidation.TrimToNull(digest);
Component = NotifyValidation.TrimToNull(component);
Image = NotifyValidation.TrimToNull(image);
Labels = NotifyValidation.NormalizeStringDictionary(labels);
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
}
public static NotifyEventScope Create(
string? @namespace = null,
string? repo = null,
string? digest = null,
string? component = null,
string? image = null,
IEnumerable<KeyValuePair<string, string>>? labels = null,
IEnumerable<KeyValuePair<string, string>>? attributes = null)
{
return new NotifyEventScope(
@namespace,
repo,
digest,
component,
image,
ToImmutableDictionary(labels),
ToImmutableDictionary(attributes));
}
public string? Namespace { get; }
public string? Repo { get; }
public string? Digest { get; }
public string? Component { get; }
public string? Image { get; }
public ImmutableDictionary<string, string> Labels { get; }
public ImmutableDictionary<string, string> Attributes { 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();
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Canonical platform event envelope consumed by Notify.
/// </summary>
public sealed record NotifyEvent
{
[JsonConstructor]
public NotifyEvent(
Guid eventId,
string kind,
string tenant,
DateTimeOffset ts,
JsonNode? payload,
NotifyEventScope? scope = null,
string? version = null,
string? actor = null,
ImmutableDictionary<string, string>? attributes = null)
{
EventId = eventId;
Kind = NotifyValidation.EnsureNotNullOrWhiteSpace(kind, nameof(kind)).ToLowerInvariant();
Tenant = NotifyValidation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
Ts = NotifyValidation.EnsureUtc(ts);
Payload = NotifyValidation.NormalizeJsonNode(payload);
Scope = scope;
Version = NotifyValidation.TrimToNull(version);
Actor = NotifyValidation.TrimToNull(actor);
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
}
public static NotifyEvent Create(
Guid eventId,
string kind,
string tenant,
DateTimeOffset ts,
JsonNode? payload,
NotifyEventScope? scope = null,
string? version = null,
string? actor = null,
IEnumerable<KeyValuePair<string, string>>? attributes = null)
{
return new NotifyEvent(
eventId,
kind,
tenant,
ts,
payload,
scope,
version,
actor,
ToImmutableDictionary(attributes));
}
public Guid EventId { get; }
public string Kind { get; }
public string Tenant { get; }
public DateTimeOffset Ts { get; }
public JsonNode? Payload { get; }
public NotifyEventScope? Scope { get; }
public string? Version { get; }
public string? Actor { get; }
public ImmutableDictionary<string, string> Attributes { 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>
/// Optional scope block describing where the event originated (namespace/repo/digest/etc.).
/// </summary>
public sealed record NotifyEventScope
{
[JsonConstructor]
public NotifyEventScope(
string? @namespace = null,
string? repo = null,
string? digest = null,
string? component = null,
string? image = null,
ImmutableDictionary<string, string>? labels = null,
ImmutableDictionary<string, string>? attributes = null)
{
Namespace = NotifyValidation.TrimToNull(@namespace);
Repo = NotifyValidation.TrimToNull(repo);
Digest = NotifyValidation.TrimToNull(digest);
Component = NotifyValidation.TrimToNull(component);
Image = NotifyValidation.TrimToNull(image);
Labels = NotifyValidation.NormalizeStringDictionary(labels);
Attributes = NotifyValidation.NormalizeStringDictionary(attributes);
}
public static NotifyEventScope Create(
string? @namespace = null,
string? repo = null,
string? digest = null,
string? component = null,
string? image = null,
IEnumerable<KeyValuePair<string, string>>? labels = null,
IEnumerable<KeyValuePair<string, string>>? attributes = null)
{
return new NotifyEventScope(
@namespace,
repo,
digest,
component,
image,
ToImmutableDictionary(labels),
ToImmutableDictionary(attributes));
}
public string? Namespace { get; }
public string? Repo { get; }
public string? Digest { get; }
public string? Component { get; }
public string? Image { get; }
public ImmutableDictionary<string, string> Labels { get; }
public ImmutableDictionary<string, string> Attributes { 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

@@ -1,18 +1,18 @@
namespace StellaOps.Notify.Models;
/// <summary>
/// Known platform event kind identifiers consumed by Notify.
/// </summary>
public static class NotifyEventKinds
{
public const string ScannerReportReady = "scanner.report.ready";
public const string ScannerScanCompleted = "scanner.scan.completed";
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
public const string AttestorLogged = "attestor.logged";
public const string ZastavaAdmission = "zastava.admission";
public const string ConselierExportCompleted = "conselier.export.completed";
public const string ExcitorExportCompleted = "excitor.export.completed";
public const string AirgapTimeDrift = "airgap.time.drift";
public const string AirgapBundleImport = "airgap.bundle.import";
public const string AirgapPortableExportCompleted = "airgap.portable.export.completed";
}
namespace StellaOps.Notify.Models;
/// <summary>
/// Known platform event kind identifiers consumed by Notify.
/// </summary>
public static class NotifyEventKinds
{
public const string ScannerReportReady = "scanner.report.ready";
public const string ScannerScanCompleted = "scanner.scan.completed";
public const string SchedulerRescanDelta = "scheduler.rescan.delta";
public const string AttestorLogged = "attestor.logged";
public const string ZastavaAdmission = "zastava.admission";
public const string ConselierExportCompleted = "conselier.export.completed";
public const string ExcitorExportCompleted = "excitor.export.completed";
public const string AirgapTimeDrift = "airgap.time.drift";
public const string AirgapBundleImport = "airgap.bundle.import";
public const string AirgapPortableExportCompleted = "airgap.portable.export.completed";
}

View File

@@ -1,233 +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; } = [];
}
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

@@ -1,494 +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; }
}
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

@@ -1,401 +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
}
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

@@ -1,388 +1,388 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Rule definition describing how platform events are matched and routed to delivery actions.
/// </summary>
public sealed record NotifyRule
{
[JsonConstructor]
public NotifyRule(
string ruleId,
string tenantId,
string name,
NotifyRuleMatch match,
ImmutableArray<NotifyRuleAction> actions,
bool enabled = true,
string? description = null,
ImmutableDictionary<string, string>? labels = null,
ImmutableDictionary<string, string>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
SchemaVersion = NotifySchemaVersions.EnsureRule(schemaVersion);
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
Description = NotifyValidation.TrimToNull(description);
Match = match ?? throw new ArgumentNullException(nameof(match));
Enabled = enabled;
Actions = NormalizeActions(actions);
if (Actions.IsDefaultOrEmpty)
{
throw new ArgumentException("At least one action is required.", nameof(actions));
}
Labels = NotifyValidation.NormalizeStringDictionary(labels);
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 NotifyRule Create(
string ruleId,
string tenantId,
string name,
NotifyRuleMatch match,
IEnumerable<NotifyRuleAction>? actions,
bool enabled = true,
string? description = null,
IEnumerable<KeyValuePair<string, string>>? labels = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
return new NotifyRule(
ruleId,
tenantId,
name,
match,
ToImmutableArray(actions),
enabled,
description,
ToImmutableDictionary(labels),
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt,
schemaVersion);
}
public string SchemaVersion { get; }
public string RuleId { get; }
public string TenantId { get; }
public string Name { get; }
public string? Description { get; }
public bool Enabled { get; }
public NotifyRuleMatch Match { get; }
public ImmutableArray<NotifyRuleAction> Actions { get; }
public ImmutableDictionary<string, string> Labels { 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<NotifyRuleAction> NormalizeActions(ImmutableArray<NotifyRuleAction> actions)
{
var source = actions.IsDefault ? Array.Empty<NotifyRuleAction>() : actions.AsEnumerable();
return source
.Where(static action => action is not null)
.Distinct()
.OrderBy(static action => action.ActionId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<NotifyRuleAction> ToImmutableArray(IEnumerable<NotifyRuleAction>? actions)
{
if (actions is null)
{
return ImmutableArray<NotifyRuleAction>.Empty;
}
return actions.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>
/// Matching criteria used to evaluate whether an event should trigger the rule.
/// </summary>
public sealed record NotifyRuleMatch
{
[JsonConstructor]
public NotifyRuleMatch(
ImmutableArray<string> eventKinds,
ImmutableArray<string> namespaces,
ImmutableArray<string> repositories,
ImmutableArray<string> digests,
ImmutableArray<string> labels,
ImmutableArray<string> componentPurls,
string? minSeverity,
ImmutableArray<string> verdicts,
bool? kevOnly,
NotifyRuleMatchVex? vex)
{
EventKinds = NormalizeStringSet(eventKinds, lowerCase: true);
Namespaces = NormalizeStringSet(namespaces);
Repositories = NormalizeStringSet(repositories);
Digests = NormalizeStringSet(digests, lowerCase: true);
Labels = NormalizeStringSet(labels);
ComponentPurls = NormalizeStringSet(componentPurls);
Verdicts = NormalizeStringSet(verdicts, lowerCase: true);
MinSeverity = NotifyValidation.TrimToNull(minSeverity)?.ToLowerInvariant();
KevOnly = kevOnly;
Vex = vex;
}
public static NotifyRuleMatch Create(
IEnumerable<string>? eventKinds = null,
IEnumerable<string>? namespaces = null,
IEnumerable<string>? repositories = null,
IEnumerable<string>? digests = null,
IEnumerable<string>? labels = null,
IEnumerable<string>? componentPurls = null,
string? minSeverity = null,
IEnumerable<string>? verdicts = null,
bool? kevOnly = null,
NotifyRuleMatchVex? vex = null)
{
return new NotifyRuleMatch(
ToImmutableArray(eventKinds),
ToImmutableArray(namespaces),
ToImmutableArray(repositories),
ToImmutableArray(digests),
ToImmutableArray(labels),
ToImmutableArray(componentPurls),
minSeverity,
ToImmutableArray(verdicts),
kevOnly,
vex);
}
public ImmutableArray<string> EventKinds { get; }
public ImmutableArray<string> Namespaces { get; }
public ImmutableArray<string> Repositories { get; }
public ImmutableArray<string> Digests { get; }
public ImmutableArray<string> Labels { get; }
public ImmutableArray<string> ComponentPurls { get; }
public string? MinSeverity { get; }
public ImmutableArray<string> Verdicts { get; }
public bool? KevOnly { get; }
public NotifyRuleMatchVex? Vex { get; }
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values, bool lowerCase = false)
{
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
var normalized = NotifyValidation.NormalizeStringSet(enumerable);
if (!lowerCase)
{
return normalized;
}
return normalized
.Select(static value => value.ToLowerInvariant())
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
return values.ToImmutableArray();
}
}
/// <summary>
/// Additional VEX (Vulnerability Exploitability eXchange) gating options.
/// </summary>
public sealed record NotifyRuleMatchVex
{
[JsonConstructor]
public NotifyRuleMatchVex(
bool includeAcceptedJustifications = true,
bool includeRejectedJustifications = false,
bool includeUnknownJustifications = false,
ImmutableArray<string> justificationKinds = default)
{
IncludeAcceptedJustifications = includeAcceptedJustifications;
IncludeRejectedJustifications = includeRejectedJustifications;
IncludeUnknownJustifications = includeUnknownJustifications;
JustificationKinds = NormalizeStringSet(justificationKinds);
}
public static NotifyRuleMatchVex Create(
bool includeAcceptedJustifications = true,
bool includeRejectedJustifications = false,
bool includeUnknownJustifications = false,
IEnumerable<string>? justificationKinds = null)
{
return new NotifyRuleMatchVex(
includeAcceptedJustifications,
includeRejectedJustifications,
includeUnknownJustifications,
ToImmutableArray(justificationKinds));
}
public bool IncludeAcceptedJustifications { get; }
public bool IncludeRejectedJustifications { get; }
public bool IncludeUnknownJustifications { get; }
public ImmutableArray<string> JustificationKinds { get; }
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values)
{
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
return NotifyValidation.NormalizeStringSet(enumerable);
}
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
return values.ToImmutableArray();
}
}
/// <summary>
/// Action executed when a rule matches an event.
/// </summary>
public sealed record NotifyRuleAction
{
[JsonConstructor]
public NotifyRuleAction(
string actionId,
string channel,
string? template = null,
string? digest = null,
TimeSpan? throttle = null,
string? locale = null,
bool enabled = true,
ImmutableDictionary<string, string>? metadata = null)
{
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
Channel = NotifyValidation.EnsureNotNullOrWhiteSpace(channel, nameof(channel));
Template = NotifyValidation.TrimToNull(template);
Digest = NotifyValidation.TrimToNull(digest);
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
Enabled = enabled;
Throttle = throttle is { Ticks: > 0 } ? throttle : null;
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
}
public static NotifyRuleAction Create(
string actionId,
string channel,
string? template = null,
string? digest = null,
TimeSpan? throttle = null,
string? locale = null,
bool enabled = true,
IEnumerable<KeyValuePair<string, string>>? metadata = null)
{
return new NotifyRuleAction(
actionId,
channel,
template,
digest,
throttle,
locale,
enabled,
ToImmutableDictionary(metadata));
}
public string ActionId { get; }
public string Channel { get; }
public string? Template { get; }
public string? Digest { get; }
public TimeSpan? Throttle { get; }
public string? Locale { get; }
public bool Enabled { get; }
public ImmutableDictionary<string, string> Metadata { 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();
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Rule definition describing how platform events are matched and routed to delivery actions.
/// </summary>
public sealed record NotifyRule
{
[JsonConstructor]
public NotifyRule(
string ruleId,
string tenantId,
string name,
NotifyRuleMatch match,
ImmutableArray<NotifyRuleAction> actions,
bool enabled = true,
string? description = null,
ImmutableDictionary<string, string>? labels = null,
ImmutableDictionary<string, string>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
SchemaVersion = NotifySchemaVersions.EnsureRule(schemaVersion);
RuleId = NotifyValidation.EnsureNotNullOrWhiteSpace(ruleId, nameof(ruleId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
Name = NotifyValidation.EnsureNotNullOrWhiteSpace(name, nameof(name));
Description = NotifyValidation.TrimToNull(description);
Match = match ?? throw new ArgumentNullException(nameof(match));
Enabled = enabled;
Actions = NormalizeActions(actions);
if (Actions.IsDefaultOrEmpty)
{
throw new ArgumentException("At least one action is required.", nameof(actions));
}
Labels = NotifyValidation.NormalizeStringDictionary(labels);
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 NotifyRule Create(
string ruleId,
string tenantId,
string name,
NotifyRuleMatch match,
IEnumerable<NotifyRuleAction>? actions,
bool enabled = true,
string? description = null,
IEnumerable<KeyValuePair<string, string>>? labels = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
return new NotifyRule(
ruleId,
tenantId,
name,
match,
ToImmutableArray(actions),
enabled,
description,
ToImmutableDictionary(labels),
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt,
schemaVersion);
}
public string SchemaVersion { get; }
public string RuleId { get; }
public string TenantId { get; }
public string Name { get; }
public string? Description { get; }
public bool Enabled { get; }
public NotifyRuleMatch Match { get; }
public ImmutableArray<NotifyRuleAction> Actions { get; }
public ImmutableDictionary<string, string> Labels { 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<NotifyRuleAction> NormalizeActions(ImmutableArray<NotifyRuleAction> actions)
{
var source = actions.IsDefault ? Array.Empty<NotifyRuleAction>() : actions.AsEnumerable();
return source
.Where(static action => action is not null)
.Distinct()
.OrderBy(static action => action.ActionId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<NotifyRuleAction> ToImmutableArray(IEnumerable<NotifyRuleAction>? actions)
{
if (actions is null)
{
return ImmutableArray<NotifyRuleAction>.Empty;
}
return actions.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>
/// Matching criteria used to evaluate whether an event should trigger the rule.
/// </summary>
public sealed record NotifyRuleMatch
{
[JsonConstructor]
public NotifyRuleMatch(
ImmutableArray<string> eventKinds,
ImmutableArray<string> namespaces,
ImmutableArray<string> repositories,
ImmutableArray<string> digests,
ImmutableArray<string> labels,
ImmutableArray<string> componentPurls,
string? minSeverity,
ImmutableArray<string> verdicts,
bool? kevOnly,
NotifyRuleMatchVex? vex)
{
EventKinds = NormalizeStringSet(eventKinds, lowerCase: true);
Namespaces = NormalizeStringSet(namespaces);
Repositories = NormalizeStringSet(repositories);
Digests = NormalizeStringSet(digests, lowerCase: true);
Labels = NormalizeStringSet(labels);
ComponentPurls = NormalizeStringSet(componentPurls);
Verdicts = NormalizeStringSet(verdicts, lowerCase: true);
MinSeverity = NotifyValidation.TrimToNull(minSeverity)?.ToLowerInvariant();
KevOnly = kevOnly;
Vex = vex;
}
public static NotifyRuleMatch Create(
IEnumerable<string>? eventKinds = null,
IEnumerable<string>? namespaces = null,
IEnumerable<string>? repositories = null,
IEnumerable<string>? digests = null,
IEnumerable<string>? labels = null,
IEnumerable<string>? componentPurls = null,
string? minSeverity = null,
IEnumerable<string>? verdicts = null,
bool? kevOnly = null,
NotifyRuleMatchVex? vex = null)
{
return new NotifyRuleMatch(
ToImmutableArray(eventKinds),
ToImmutableArray(namespaces),
ToImmutableArray(repositories),
ToImmutableArray(digests),
ToImmutableArray(labels),
ToImmutableArray(componentPurls),
minSeverity,
ToImmutableArray(verdicts),
kevOnly,
vex);
}
public ImmutableArray<string> EventKinds { get; }
public ImmutableArray<string> Namespaces { get; }
public ImmutableArray<string> Repositories { get; }
public ImmutableArray<string> Digests { get; }
public ImmutableArray<string> Labels { get; }
public ImmutableArray<string> ComponentPurls { get; }
public string? MinSeverity { get; }
public ImmutableArray<string> Verdicts { get; }
public bool? KevOnly { get; }
public NotifyRuleMatchVex? Vex { get; }
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values, bool lowerCase = false)
{
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
var normalized = NotifyValidation.NormalizeStringSet(enumerable);
if (!lowerCase)
{
return normalized;
}
return normalized
.Select(static value => value.ToLowerInvariant())
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
return values.ToImmutableArray();
}
}
/// <summary>
/// Additional VEX (Vulnerability Exploitability eXchange) gating options.
/// </summary>
public sealed record NotifyRuleMatchVex
{
[JsonConstructor]
public NotifyRuleMatchVex(
bool includeAcceptedJustifications = true,
bool includeRejectedJustifications = false,
bool includeUnknownJustifications = false,
ImmutableArray<string> justificationKinds = default)
{
IncludeAcceptedJustifications = includeAcceptedJustifications;
IncludeRejectedJustifications = includeRejectedJustifications;
IncludeUnknownJustifications = includeUnknownJustifications;
JustificationKinds = NormalizeStringSet(justificationKinds);
}
public static NotifyRuleMatchVex Create(
bool includeAcceptedJustifications = true,
bool includeRejectedJustifications = false,
bool includeUnknownJustifications = false,
IEnumerable<string>? justificationKinds = null)
{
return new NotifyRuleMatchVex(
includeAcceptedJustifications,
includeRejectedJustifications,
includeUnknownJustifications,
ToImmutableArray(justificationKinds));
}
public bool IncludeAcceptedJustifications { get; }
public bool IncludeRejectedJustifications { get; }
public bool IncludeUnknownJustifications { get; }
public ImmutableArray<string> JustificationKinds { get; }
private static ImmutableArray<string> NormalizeStringSet(ImmutableArray<string> values)
{
var enumerable = values.IsDefault ? Array.Empty<string>() : values.AsEnumerable();
return NotifyValidation.NormalizeStringSet(enumerable);
}
private static ImmutableArray<string> ToImmutableArray(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableArray<string>.Empty;
}
return values.ToImmutableArray();
}
}
/// <summary>
/// Action executed when a rule matches an event.
/// </summary>
public sealed record NotifyRuleAction
{
[JsonConstructor]
public NotifyRuleAction(
string actionId,
string channel,
string? template = null,
string? digest = null,
TimeSpan? throttle = null,
string? locale = null,
bool enabled = true,
ImmutableDictionary<string, string>? metadata = null)
{
ActionId = NotifyValidation.EnsureNotNullOrWhiteSpace(actionId, nameof(actionId));
Channel = NotifyValidation.EnsureNotNullOrWhiteSpace(channel, nameof(channel));
Template = NotifyValidation.TrimToNull(template);
Digest = NotifyValidation.TrimToNull(digest);
Locale = NotifyValidation.TrimToNull(locale)?.ToLowerInvariant();
Enabled = enabled;
Throttle = throttle is { Ticks: > 0 } ? throttle : null;
Metadata = NotifyValidation.NormalizeStringDictionary(metadata);
}
public static NotifyRuleAction Create(
string actionId,
string channel,
string? template = null,
string? digest = null,
TimeSpan? throttle = null,
string? locale = null,
bool enabled = true,
IEnumerable<KeyValuePair<string, string>>? metadata = null)
{
return new NotifyRuleAction(
actionId,
channel,
template,
digest,
throttle,
locale,
enabled,
ToImmutableDictionary(metadata));
}
public string ActionId { get; }
public string Channel { get; }
public string? Template { get; }
public string? Digest { get; }
public TimeSpan? Throttle { get; }
public string? Locale { get; }
public bool Enabled { get; }
public ImmutableDictionary<string, string> Metadata { 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

@@ -1,74 +1,74 @@
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models;
/// <summary>
/// Upgrades Notify documents emitted by older schema revisions to the current DTOs.
/// </summary>
public static class NotifySchemaMigration
{
public static NotifyRule UpgradeRule(JsonNode document)
{
ArgumentNullException.ThrowIfNull(document);
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Rule);
return schemaVersion switch
{
NotifySchemaVersions.Rule => Deserialize<NotifyRule>(clone),
_ => throw new NotSupportedException($"Unsupported notify rule schema version '{schemaVersion}'.")
};
}
public static NotifyChannel UpgradeChannel(JsonNode document)
{
ArgumentNullException.ThrowIfNull(document);
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Channel);
return schemaVersion switch
{
NotifySchemaVersions.Channel => Deserialize<NotifyChannel>(clone),
_ => throw new NotSupportedException($"Unsupported notify channel schema version '{schemaVersion}'.")
};
}
public static NotifyTemplate UpgradeTemplate(JsonNode document)
{
ArgumentNullException.ThrowIfNull(document);
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Template);
return schemaVersion switch
{
NotifySchemaVersions.Template => Deserialize<NotifyTemplate>(clone),
_ => throw new NotSupportedException($"Unsupported notify template schema version '{schemaVersion}'.")
};
}
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, string fallback)
{
if (node is not JsonObject obj)
{
throw new ArgumentException("Document must be a JSON object.", nameof(node));
}
if (obj.DeepClone() is not JsonObject clone)
{
throw new InvalidOperationException("Unable to clone document as JsonObject.");
}
string schemaVersion;
if (clone.TryGetPropertyValue("schemaVersion", out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue(out string? version) && !string.IsNullOrWhiteSpace(version))
{
schemaVersion = version.Trim();
}
else
{
schemaVersion = fallback;
clone["schemaVersion"] = schemaVersion;
}
return (clone, schemaVersion);
}
private static T Deserialize<T>(JsonObject json)
=> NotifyCanonicalJsonSerializer.Deserialize<T>(json.ToJsonString());
}
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models;
/// <summary>
/// Upgrades Notify documents emitted by older schema revisions to the current DTOs.
/// </summary>
public static class NotifySchemaMigration
{
public static NotifyRule UpgradeRule(JsonNode document)
{
ArgumentNullException.ThrowIfNull(document);
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Rule);
return schemaVersion switch
{
NotifySchemaVersions.Rule => Deserialize<NotifyRule>(clone),
_ => throw new NotSupportedException($"Unsupported notify rule schema version '{schemaVersion}'.")
};
}
public static NotifyChannel UpgradeChannel(JsonNode document)
{
ArgumentNullException.ThrowIfNull(document);
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Channel);
return schemaVersion switch
{
NotifySchemaVersions.Channel => Deserialize<NotifyChannel>(clone),
_ => throw new NotSupportedException($"Unsupported notify channel schema version '{schemaVersion}'.")
};
}
public static NotifyTemplate UpgradeTemplate(JsonNode document)
{
ArgumentNullException.ThrowIfNull(document);
var (clone, schemaVersion) = Normalize(document, NotifySchemaVersions.Template);
return schemaVersion switch
{
NotifySchemaVersions.Template => Deserialize<NotifyTemplate>(clone),
_ => throw new NotSupportedException($"Unsupported notify template schema version '{schemaVersion}'.")
};
}
private static (JsonObject Clone, string SchemaVersion) Normalize(JsonNode node, string fallback)
{
if (node is not JsonObject obj)
{
throw new ArgumentException("Document must be a JSON object.", nameof(node));
}
if (obj.DeepClone() is not JsonObject clone)
{
throw new InvalidOperationException("Unable to clone document as JsonObject.");
}
string schemaVersion;
if (clone.TryGetPropertyValue("schemaVersion", out var value) && value is JsonValue jsonValue && jsonValue.TryGetValue(out string? version) && !string.IsNullOrWhiteSpace(version))
{
schemaVersion = version.Trim();
}
else
{
schemaVersion = fallback;
clone["schemaVersion"] = schemaVersion;
}
return (clone, schemaVersion);
}
private static T Deserialize<T>(JsonObject json)
=> NotifyCanonicalJsonSerializer.Deserialize<T>(json.ToJsonString());
}

View File

@@ -1,23 +1,23 @@
namespace StellaOps.Notify.Models;
/// <summary>
/// Canonical schema version identifiers for Notify documents.
/// </summary>
public static class NotifySchemaVersions
{
public const string Rule = "notify.rule@1";
public const string Channel = "notify.channel@1";
public const string Template = "notify.template@1";
public static string EnsureRule(string? value)
=> Normalize(value, Rule);
public static string EnsureChannel(string? value)
=> Normalize(value, Channel);
public static string EnsureTemplate(string? value)
=> Normalize(value, Template);
private static string Normalize(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
namespace StellaOps.Notify.Models;
/// <summary>
/// Canonical schema version identifiers for Notify documents.
/// </summary>
public static class NotifySchemaVersions
{
public const string Rule = "notify.rule@1";
public const string Channel = "notify.channel@1";
public const string Template = "notify.template@1";
public static string EnsureRule(string? value)
=> Normalize(value, Rule);
public static string EnsureChannel(string? value)
=> Normalize(value, Channel);
public static string EnsureTemplate(string? value)
=> Normalize(value, Template);
private static string Normalize(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}

View File

@@ -1,130 +1,130 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Stored template metadata and content for channel-specific rendering.
/// </summary>
public sealed record NotifyTemplate
{
[JsonConstructor]
public NotifyTemplate(
string templateId,
string tenantId,
NotifyChannelType channelType,
string key,
string locale,
string body,
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
string? description = null,
ImmutableDictionary<string, string>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
SchemaVersion = NotifySchemaVersions.EnsureTemplate(schemaVersion);
TemplateId = NotifyValidation.EnsureNotNullOrWhiteSpace(templateId, nameof(templateId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
ChannelType = channelType;
Key = NotifyValidation.EnsureNotNullOrWhiteSpace(key, nameof(key));
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
Description = NotifyValidation.TrimToNull(description);
RenderMode = renderMode;
Format = format;
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 NotifyTemplate Create(
string templateId,
string tenantId,
NotifyChannelType channelType,
string key,
string locale,
string body,
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
string? description = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
return new NotifyTemplate(
templateId,
tenantId,
channelType,
key,
locale,
body,
renderMode,
format,
description,
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt,
schemaVersion);
}
public string SchemaVersion { get; }
public string TemplateId { get; }
public string TenantId { get; }
public NotifyChannelType ChannelType { get; }
public string Key { get; }
public string Locale { get; }
public string Body { get; }
public string? Description { get; }
public NotifyTemplateRenderMode RenderMode { get; }
public NotifyDeliveryFormat Format { 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();
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Serialization;
namespace StellaOps.Notify.Models;
/// <summary>
/// Stored template metadata and content for channel-specific rendering.
/// </summary>
public sealed record NotifyTemplate
{
[JsonConstructor]
public NotifyTemplate(
string templateId,
string tenantId,
NotifyChannelType channelType,
string key,
string locale,
string body,
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
string? description = null,
ImmutableDictionary<string, string>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
SchemaVersion = NotifySchemaVersions.EnsureTemplate(schemaVersion);
TemplateId = NotifyValidation.EnsureNotNullOrWhiteSpace(templateId, nameof(templateId));
TenantId = NotifyValidation.EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId));
ChannelType = channelType;
Key = NotifyValidation.EnsureNotNullOrWhiteSpace(key, nameof(key));
Locale = NotifyValidation.EnsureNotNullOrWhiteSpace(locale, nameof(locale)).ToLowerInvariant();
Body = NotifyValidation.EnsureNotNullOrWhiteSpace(body, nameof(body));
Description = NotifyValidation.TrimToNull(description);
RenderMode = renderMode;
Format = format;
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 NotifyTemplate Create(
string templateId,
string tenantId,
NotifyChannelType channelType,
string key,
string locale,
string body,
NotifyTemplateRenderMode renderMode = NotifyTemplateRenderMode.Markdown,
NotifyDeliveryFormat format = NotifyDeliveryFormat.Json,
string? description = null,
IEnumerable<KeyValuePair<string, string>>? metadata = null,
string? createdBy = null,
DateTimeOffset? createdAt = null,
string? updatedBy = null,
DateTimeOffset? updatedAt = null,
string? schemaVersion = null)
{
return new NotifyTemplate(
templateId,
tenantId,
channelType,
key,
locale,
body,
renderMode,
format,
description,
ToImmutableDictionary(metadata),
createdBy,
createdAt,
updatedBy,
updatedAt,
schemaVersion);
}
public string SchemaVersion { get; }
public string TemplateId { get; }
public string TenantId { get; }
public NotifyChannelType ChannelType { get; }
public string Key { get; }
public string Locale { get; }
public string Body { get; }
public string? Description { get; }
public NotifyTemplateRenderMode RenderMode { get; }
public NotifyDeliveryFormat Format { 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

@@ -1,157 +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();
}
}
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

@@ -1,98 +1,98 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models;
/// <summary>
/// Lightweight validation helpers shared across Notify model constructors.
/// </summary>
public static class NotifyValidation
{
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
public static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values)
=> (values ?? Array.Empty<string>())
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
public static ImmutableDictionary<string, string> NormalizeStringDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
{
if (pairs is null)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in pairs)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var normalizedKey = key.Trim();
var normalizedValue = value?.Trim() ?? string.Empty;
builder[normalizedKey] = normalizedValue;
}
return ImmutableDictionary.CreateRange(StringComparer.Ordinal, builder);
}
public static DateTimeOffset EnsureUtc(DateTimeOffset value)
=> value.ToUniversalTime();
public static DateTimeOffset? EnsureUtc(DateTimeOffset? value)
=> value?.ToUniversalTime();
public static JsonNode? NormalizeJsonNode(JsonNode? node)
{
if (node is null)
{
return null;
}
switch (node)
{
case JsonObject jsonObject:
{
var normalized = new JsonObject();
foreach (var property in jsonObject
.Where(static pair => pair.Key is not null)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
normalized[property.Key!] = NormalizeJsonNode(property.Value?.DeepClone());
}
return normalized;
}
case JsonArray jsonArray:
{
var normalized = new JsonArray();
foreach (var element in jsonArray)
{
normalized.Add(NormalizeJsonNode(element?.DeepClone()));
}
return normalized;
}
default:
return node.DeepClone();
}
}
}
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models;
/// <summary>
/// Lightweight validation helpers shared across Notify model constructors.
/// </summary>
public static class NotifyValidation
{
public static string EnsureNotNullOrWhiteSpace(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
public static string? TrimToNull(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
public static ImmutableArray<string> NormalizeStringSet(IEnumerable<string>? values)
=> (values ?? Array.Empty<string>())
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
public static ImmutableDictionary<string, string> NormalizeStringDictionary(IEnumerable<KeyValuePair<string, string>>? pairs)
{
if (pairs is null)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var (key, value) in pairs)
{
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var normalizedKey = key.Trim();
var normalizedValue = value?.Trim() ?? string.Empty;
builder[normalizedKey] = normalizedValue;
}
return ImmutableDictionary.CreateRange(StringComparer.Ordinal, builder);
}
public static DateTimeOffset EnsureUtc(DateTimeOffset value)
=> value.ToUniversalTime();
public static DateTimeOffset? EnsureUtc(DateTimeOffset? value)
=> value?.ToUniversalTime();
public static JsonNode? NormalizeJsonNode(JsonNode? node)
{
if (node is null)
{
return null;
}
switch (node)
{
case JsonObject jsonObject:
{
var normalized = new JsonObject();
foreach (var property in jsonObject
.Where(static pair => pair.Key is not null)
.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
{
normalized[property.Key!] = NormalizeJsonNode(property.Value?.DeepClone());
}
return normalized;
}
case JsonArray jsonArray:
{
var normalized = new JsonArray();
foreach (var element in jsonArray)
{
normalized.Add(NormalizeJsonNode(element?.DeepClone()));
}
return normalized;
}
default:
return node.DeepClone();
}
}
}

View File

@@ -1,80 +1,80 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly NatsNotifyDeliveryQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyDeliveryLease(
NatsNotifyDeliveryQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyDeliveryQueueMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string idempotencyKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly NatsNotifyDeliveryQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyDeliveryLease(
NatsNotifyDeliveryQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyDeliveryQueueMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string idempotencyKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
IdempotencyKey = idempotencyKey ?? payload.IdempotencyKey;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,83 +1,83 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NatsNotifyEventQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyEventLease(
NatsNotifyEventQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyQueueEventMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
{
throw new ArgumentException("Message must be provided.", nameof(message));
}
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NATS.Client.JetStream;
namespace StellaOps.Notify.Queue.Nats;
internal sealed class NatsNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NatsNotifyEventQueue _queue;
private readonly NatsJSMsg<byte[]> _message;
private int _completed;
internal NatsNotifyEventLease(
NatsNotifyEventQueue queue,
NatsJSMsg<byte[]> message,
string messageId,
NotifyQueueEventMessage payload,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
if (EqualityComparer<NatsJSMsg<byte[]>>.Default.Equals(message, default))
{
throw new ArgumentException("Message must be provided.", nameof(message));
}
_message = message;
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = payload ?? throw new ArgumentNullException(nameof(payload));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
internal NatsJSMsg<byte[]> RawMessage => _message;
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
{
private readonly INotifyDeliveryQueue _queue;
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
public NotifyDeliveryQueueHealthCheck(
INotifyDeliveryQueue queue,
ILogger<NotifyDeliveryQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyDeliveryQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
case NatsNotifyDeliveryQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
default:
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify delivery queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify delivery queue transport unreachable.",
ex);
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyDeliveryQueueHealthCheck : IHealthCheck
{
private readonly INotifyDeliveryQueue _queue;
private readonly ILogger<NotifyDeliveryQueueHealthCheck> _logger;
public NotifyDeliveryQueueHealthCheck(
INotifyDeliveryQueue queue,
ILogger<NotifyDeliveryQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyDeliveryQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify delivery queue reachable.");
case NatsNotifyDeliveryQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify delivery queue reachable.");
default:
return HealthCheckResult.Healthy("Notify delivery queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify delivery queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify delivery queue transport unreachable.",
ex);
}
}
}

View File

@@ -1,69 +1,69 @@
using System;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify delivery queue abstraction.
/// </summary>
public sealed class NotifyDeliveryQueueOptions
{
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxDeliveryAttempts { get; set; } = 5;
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
public sealed class NotifyRedisDeliveryQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public string StreamName { get; set; } = "notify:deliveries";
public string ConsumerGroup { get; set; } = "notify-deliveries";
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
public int? ApproximateMaxLength { get; set; }
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
}
public sealed class NotifyNatsDeliveryQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
public string Subject { get; set; } = "notify.deliveries";
public string DurableConsumer { get; set; } = "notify-deliveries";
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
public int MaxAckPending { get; set; } = 128;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}
using System;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify delivery queue abstraction.
/// </summary>
public sealed class NotifyDeliveryQueueOptions
{
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
public NotifyRedisDeliveryQueueOptions Redis { get; set; } = new();
public NotifyNatsDeliveryQueueOptions Nats { get; set; } = new();
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxDeliveryAttempts { get; set; } = 5;
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
public sealed class NotifyRedisDeliveryQueueOptions
{
public string? ConnectionString { get; set; }
public int? Database { get; set; }
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
public string StreamName { get; set; } = "notify:deliveries";
public string ConsumerGroup { get; set; } = "notify-deliveries";
public string IdempotencyKeyPrefix { get; set; } = "notify:deliveries:idemp:";
public int? ApproximateMaxLength { get; set; }
public string DeadLetterStreamName { get; set; } = "notify:deliveries:dead";
public TimeSpan DeadLetterRetention { get; set; } = TimeSpan.FromDays(7);
}
public sealed class NotifyNatsDeliveryQueueOptions
{
public string? Url { get; set; }
public string Stream { get; set; } = "NOTIFY_DELIVERIES";
public string Subject { get; set; } = "notify.deliveries";
public string DurableConsumer { get; set; } = "notify-deliveries";
public string DeadLetterStream { get; set; } = "NOTIFY_DELIVERIES_DEAD";
public string DeadLetterSubject { get; set; } = "notify.deliveries.dead";
public int MaxAckPending { get; set; } = 128;
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -1,177 +1,177 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify event queue abstraction.
/// </summary>
public sealed class NotifyEventQueueOptions
{
/// <summary>
/// Transport backing the queue.
/// </summary>
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
/// <summary>
/// Redis-specific configuration.
/// </summary>
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
/// <summary>
/// NATS JetStream-specific configuration.
/// </summary>
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration to use when consumers do not specify one explicitly.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of deliveries before a message should be considered failed.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Initial retry backoff applied when a message is released for retry.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Cap applied to exponential retry backoff.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Minimum idle window before a pending message becomes eligible for claim.
/// </summary>
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Redis transport options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventQueueOptions
{
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
{
NotifyRedisEventStreamOptions.ForDefaultStream()
};
/// <summary>
/// Connection string for the Redis instance.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Optional logical database to select when connecting.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Time allowed for initial connection/consumer-group creation.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// TTL applied to idempotency keys stored alongside events.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Streams consumed by Notify. Ordering is preserved during leasing.
/// </summary>
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
{
get => _streams;
set => _streams = value is null || value.Count == 0
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
: value;
}
}
/// <summary>
/// Per-Redis-stream options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventStreamOptions
{
/// <summary>
/// Name of the Redis stream containing events.
/// </summary>
public string Stream { get; set; } = "notify:events";
/// <summary>
/// Consumer group used by Notify workers.
/// </summary>
public string ConsumerGroup { get; set; } = "notify-workers";
/// <summary>
/// Prefix used when storing idempotency keys in Redis.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
/// <summary>
/// Approximate maximum length for the stream; when set Redis will trim entries.
/// </summary>
public int? ApproximateMaxLength { get; set; }
public static NotifyRedisEventStreamOptions ForDefaultStream()
=> new();
}
/// <summary>
/// NATS JetStream options for the Notify event queue.
/// </summary>
public sealed class NotifyNatsEventQueueOptions
{
/// <summary>
/// URL for the JetStream-enabled NATS cluster.
/// </summary>
public string? Url { get; set; }
/// <summary>
/// Stream name carrying Notify events.
/// </summary>
public string Stream { get; set; } = "NOTIFY_EVENTS";
/// <summary>
/// Subject that producers publish Notify events to.
/// </summary>
public string Subject { get; set; } = "notify.events";
/// <summary>
/// Durable consumer identifier for Notify workers.
/// </summary>
public string DurableConsumer { get; set; } = "notify-workers";
/// <summary>
/// Dead-letter stream name used when deliveries exhaust retry budget.
/// </summary>
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
/// <summary>
/// Subject used for dead-letter publications.
/// </summary>
public string DeadLetterSubject { get; set; } = "notify.events.dead";
/// <summary>
/// Maximum pending messages before backpressure is applied.
/// </summary>
public int MaxAckPending { get; set; } = 256;
/// <summary>
/// Visibility timeout applied to leased events.
/// </summary>
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Delay applied when releasing a message for retry.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Idle heartbeat emitted by the server to detect consumer disconnects.
/// </summary>
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}
using System;
using System.Collections.Generic;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Configuration options for the Notify event queue abstraction.
/// </summary>
public sealed class NotifyEventQueueOptions
{
/// <summary>
/// Transport backing the queue.
/// </summary>
public NotifyQueueTransportKind Transport { get; set; } = NotifyQueueTransportKind.Redis;
/// <summary>
/// Redis-specific configuration.
/// </summary>
public NotifyRedisEventQueueOptions Redis { get; set; } = new();
/// <summary>
/// NATS JetStream-specific configuration.
/// </summary>
public NotifyNatsEventQueueOptions Nats { get; set; } = new();
/// <summary>
/// Default lease duration to use when consumers do not specify one explicitly.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum number of deliveries before a message should be considered failed.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Initial retry backoff applied when a message is released for retry.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Cap applied to exponential retry backoff.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Minimum idle window before a pending message becomes eligible for claim.
/// </summary>
public TimeSpan ClaimIdleThreshold { get; set; } = TimeSpan.FromMinutes(5);
}
/// <summary>
/// Redis transport options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventQueueOptions
{
private IReadOnlyList<NotifyRedisEventStreamOptions> _streams = new List<NotifyRedisEventStreamOptions>
{
NotifyRedisEventStreamOptions.ForDefaultStream()
};
/// <summary>
/// Connection string for the Redis instance.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Optional logical database to select when connecting.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Time allowed for initial connection/consumer-group creation.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// TTL applied to idempotency keys stored alongside events.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Streams consumed by Notify. Ordering is preserved during leasing.
/// </summary>
public IReadOnlyList<NotifyRedisEventStreamOptions> Streams
{
get => _streams;
set => _streams = value is null || value.Count == 0
? new List<NotifyRedisEventStreamOptions> { NotifyRedisEventStreamOptions.ForDefaultStream() }
: value;
}
}
/// <summary>
/// Per-Redis-stream options for the Notify event queue.
/// </summary>
public sealed class NotifyRedisEventStreamOptions
{
/// <summary>
/// Name of the Redis stream containing events.
/// </summary>
public string Stream { get; set; } = "notify:events";
/// <summary>
/// Consumer group used by Notify workers.
/// </summary>
public string ConsumerGroup { get; set; } = "notify-workers";
/// <summary>
/// Prefix used when storing idempotency keys in Redis.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "notify:events:idemp:";
/// <summary>
/// Approximate maximum length for the stream; when set Redis will trim entries.
/// </summary>
public int? ApproximateMaxLength { get; set; }
public static NotifyRedisEventStreamOptions ForDefaultStream()
=> new();
}
/// <summary>
/// NATS JetStream options for the Notify event queue.
/// </summary>
public sealed class NotifyNatsEventQueueOptions
{
/// <summary>
/// URL for the JetStream-enabled NATS cluster.
/// </summary>
public string? Url { get; set; }
/// <summary>
/// Stream name carrying Notify events.
/// </summary>
public string Stream { get; set; } = "NOTIFY_EVENTS";
/// <summary>
/// Subject that producers publish Notify events to.
/// </summary>
public string Subject { get; set; } = "notify.events";
/// <summary>
/// Durable consumer identifier for Notify workers.
/// </summary>
public string DurableConsumer { get; set; } = "notify-workers";
/// <summary>
/// Dead-letter stream name used when deliveries exhaust retry budget.
/// </summary>
public string DeadLetterStream { get; set; } = "NOTIFY_EVENTS_DEAD";
/// <summary>
/// Subject used for dead-letter publications.
/// </summary>
public string DeadLetterSubject { get; set; } = "notify.events.dead";
/// <summary>
/// Maximum pending messages before backpressure is applied.
/// </summary>
public int MaxAckPending { get; set; } = 256;
/// <summary>
/// Visibility timeout applied to leased events.
/// </summary>
public TimeSpan AckWait { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Delay applied when releasing a message for retry.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Idle heartbeat emitted by the server to detect consumer disconnects.
/// </summary>
public TimeSpan IdleHeartbeat { get; set; } = TimeSpan.FromSeconds(30);
}

View File

@@ -1,231 +1,231 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Message queued for Notify event processing.
/// </summary>
public sealed class NotifyQueueEventMessage
{
private readonly NotifyEvent _event;
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyQueueEventMessage(
NotifyEvent @event,
string stream,
string? idempotencyKey = null,
string? partitionKey = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
_event = @event ?? throw new ArgumentNullException(nameof(@event));
if (string.IsNullOrWhiteSpace(stream))
{
throw new ArgumentException("Stream must be provided.", nameof(stream));
}
Stream = stream;
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
? @event.EventId.ToString("N")
: idempotencyKey!;
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyEvent Event => _event;
public string Stream { get; }
public string IdempotencyKey { get; }
public string TenantId => _event.Tenant;
public string? PartitionKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
/// <summary>
/// Message queued for channel delivery execution.
/// </summary>
public sealed class NotifyDeliveryQueueMessage
{
public const string DefaultStream = "notify:deliveries";
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyDeliveryQueueMessage(
NotifyDelivery delivery,
string channelId,
NotifyChannelType channelType,
string? stream = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
ChannelType = channelType;
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyDelivery Delivery { get; }
public string ChannelId { get; }
public NotifyChannelType ChannelType { get; }
public string Stream { get; }
public string? TraceId { get; }
public string TenantId => Delivery.TenantId;
public string IdempotencyKey => Delivery.DeliveryId;
public string PartitionKey => ChannelId;
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class NotifyQueueLeaseRequest
{
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class NotifyQueueClaimOptions
{
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public string ClaimantConsumer { get; }
public int BatchSize { get; }
public TimeSpan MinIdleTime { get; }
}
public enum NotifyQueueReleaseDisposition
{
Retry,
Abandon
}
public interface INotifyQueue<TMessage>
{
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface INotifyQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string Stream { get; }
string TenantId { get; }
string? PartitionKey { get; }
string IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
{
}
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
{
}
internal static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue;
/// <summary>
/// Message queued for Notify event processing.
/// </summary>
public sealed class NotifyQueueEventMessage
{
private readonly NotifyEvent _event;
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyQueueEventMessage(
NotifyEvent @event,
string stream,
string? idempotencyKey = null,
string? partitionKey = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
_event = @event ?? throw new ArgumentNullException(nameof(@event));
if (string.IsNullOrWhiteSpace(stream))
{
throw new ArgumentException("Stream must be provided.", nameof(stream));
}
Stream = stream;
IdempotencyKey = string.IsNullOrWhiteSpace(idempotencyKey)
? @event.EventId.ToString("N")
: idempotencyKey!;
PartitionKey = string.IsNullOrWhiteSpace(partitionKey) ? null : partitionKey.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyEvent Event => _event;
public string Stream { get; }
public string IdempotencyKey { get; }
public string TenantId => _event.Tenant;
public string? PartitionKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
/// <summary>
/// Message queued for channel delivery execution.
/// </summary>
public sealed class NotifyDeliveryQueueMessage
{
public const string DefaultStream = "notify:deliveries";
private readonly IReadOnlyDictionary<string, string> _attributes;
public NotifyDeliveryQueueMessage(
NotifyDelivery delivery,
string channelId,
NotifyChannelType channelType,
string? stream = null,
string? traceId = null,
IReadOnlyDictionary<string, string>? attributes = null)
{
Delivery = delivery ?? throw new ArgumentNullException(nameof(delivery));
ChannelId = NotifyValidation.EnsureNotNullOrWhiteSpace(channelId, nameof(channelId));
ChannelType = channelType;
Stream = string.IsNullOrWhiteSpace(stream) ? DefaultStream : stream!.Trim();
TraceId = string.IsNullOrWhiteSpace(traceId) ? null : traceId.Trim();
_attributes = attributes is null
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(attributes, StringComparer.Ordinal));
}
public NotifyDelivery Delivery { get; }
public string ChannelId { get; }
public NotifyChannelType ChannelType { get; }
public string Stream { get; }
public string? TraceId { get; }
public string TenantId => Delivery.TenantId;
public string IdempotencyKey => Delivery.DeliveryId;
public string PartitionKey => ChannelId;
public IReadOnlyDictionary<string, string> Attributes => _attributes;
}
public readonly record struct NotifyQueueEnqueueResult(string MessageId, bool Deduplicated);
public sealed class NotifyQueueLeaseRequest
{
public NotifyQueueLeaseRequest(string consumer, int batchSize, TimeSpan leaseDuration)
{
if (string.IsNullOrWhiteSpace(consumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(consumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (leaseDuration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(leaseDuration), leaseDuration, "Lease duration must be positive.");
}
Consumer = consumer;
BatchSize = batchSize;
LeaseDuration = leaseDuration;
}
public string Consumer { get; }
public int BatchSize { get; }
public TimeSpan LeaseDuration { get; }
}
public sealed class NotifyQueueClaimOptions
{
public NotifyQueueClaimOptions(string claimantConsumer, int batchSize, TimeSpan minIdleTime)
{
if (string.IsNullOrWhiteSpace(claimantConsumer))
{
throw new ArgumentException("Consumer must be provided.", nameof(claimantConsumer));
}
if (batchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be positive.");
}
if (minIdleTime < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(minIdleTime), minIdleTime, "Minimum idle time cannot be negative.");
}
ClaimantConsumer = claimantConsumer;
BatchSize = batchSize;
MinIdleTime = minIdleTime;
}
public string ClaimantConsumer { get; }
public int BatchSize { get; }
public TimeSpan MinIdleTime { get; }
}
public enum NotifyQueueReleaseDisposition
{
Retry,
Abandon
}
public interface INotifyQueue<TMessage>
{
ValueTask<NotifyQueueEnqueueResult> PublishAsync(TMessage message, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default);
ValueTask<IReadOnlyList<INotifyQueueLease<TMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default);
}
public interface INotifyQueueLease<out TMessage>
{
string MessageId { get; }
int Attempt { get; }
DateTimeOffset EnqueuedAt { get; }
DateTimeOffset LeaseExpiresAt { get; }
string Consumer { get; }
string Stream { get; }
string TenantId { get; }
string? PartitionKey { get; }
string IdempotencyKey { get; }
string? TraceId { get; }
IReadOnlyDictionary<string, string> Attributes { get; }
TMessage Message { get; }
Task AcknowledgeAsync(CancellationToken cancellationToken = default);
Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default);
Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default);
Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
public interface INotifyEventQueue : INotifyQueue<NotifyQueueEventMessage>
{
}
public interface INotifyDeliveryQueue : INotifyQueue<NotifyDeliveryQueueMessage>
{
}
internal static class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}

View File

@@ -1,18 +1,18 @@
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueFields
{
public const string Payload = "payload";
public const string EventId = "eventId";
public const string DeliveryId = "deliveryId";
public const string Tenant = "tenant";
public const string Kind = "kind";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string PartitionKey = "partitionKey";
public const string ChannelId = "channelId";
public const string ChannelType = "channelType";
public const string IdempotencyKey = "idempotency";
public const string AttributePrefix = "attr:";
}
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueFields
{
public const string Payload = "payload";
public const string EventId = "eventId";
public const string DeliveryId = "deliveryId";
public const string Tenant = "tenant";
public const string Kind = "kind";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enqueuedAt";
public const string TraceId = "traceId";
public const string PartitionKey = "partitionKey";
public const string ChannelId = "channelId";
public const string ChannelType = "channelType";
public const string IdempotencyKey = "idempotency";
public const string AttributePrefix = "attr:";
}

View File

@@ -1,55 +1,55 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyQueueHealthCheck : IHealthCheck
{
private readonly INotifyEventQueue _queue;
private readonly ILogger<NotifyQueueHealthCheck> _logger;
public NotifyQueueHealthCheck(
INotifyEventQueue queue,
ILogger<NotifyQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyEventQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
case NatsNotifyEventQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
default:
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify queue transport unreachable.",
ex);
}
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public sealed class NotifyQueueHealthCheck : IHealthCheck
{
private readonly INotifyEventQueue _queue;
private readonly ILogger<NotifyQueueHealthCheck> _logger;
public NotifyQueueHealthCheck(
INotifyEventQueue queue,
ILogger<NotifyQueueHealthCheck> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
switch (_queue)
{
case RedisNotifyEventQueue redisQueue:
await redisQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("Redis Notify queue reachable.");
case NatsNotifyEventQueue natsQueue:
await natsQueue.PingAsync(cancellationToken).ConfigureAwait(false);
return HealthCheckResult.Healthy("NATS Notify queue reachable.");
default:
return HealthCheckResult.Healthy("Notify queue transport without dedicated ping returned healthy.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Notify queue health check failed.");
return new HealthCheckResult(
context.Registration.FailureStatus,
"Notify queue transport unreachable.",
ex);
}
}
}

View File

@@ -1,39 +1,39 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueMetrics
{
private const string TransportTag = "transport";
private const string StreamTag = "stream";
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
public static void RecordEnqueued(string transport, string stream)
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeduplicated(string transport, string stream)
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
public static void RecordAck(string transport, string stream)
=> AckCounter.Add(1, BuildTags(transport, stream));
public static void RecordRetry(string transport, string stream)
=> RetryCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeadLetter(string transport, string stream)
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
=> new[]
{
new KeyValuePair<string, object?>(TransportTag, transport),
new KeyValuePair<string, object?>(StreamTag, stream)
};
}
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Notify.Queue;
internal static class NotifyQueueMetrics
{
private const string TransportTag = "transport";
private const string StreamTag = "stream";
private static readonly Meter Meter = new("StellaOps.Notify.Queue");
private static readonly Counter<long> EnqueuedCounter = Meter.CreateCounter<long>("notify_queue_enqueued_total");
private static readonly Counter<long> DeduplicatedCounter = Meter.CreateCounter<long>("notify_queue_deduplicated_total");
private static readonly Counter<long> AckCounter = Meter.CreateCounter<long>("notify_queue_ack_total");
private static readonly Counter<long> RetryCounter = Meter.CreateCounter<long>("notify_queue_retry_total");
private static readonly Counter<long> DeadLetterCounter = Meter.CreateCounter<long>("notify_queue_deadletter_total");
public static void RecordEnqueued(string transport, string stream)
=> EnqueuedCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeduplicated(string transport, string stream)
=> DeduplicatedCounter.Add(1, BuildTags(transport, stream));
public static void RecordAck(string transport, string stream)
=> AckCounter.Add(1, BuildTags(transport, stream));
public static void RecordRetry(string transport, string stream)
=> RetryCounter.Add(1, BuildTags(transport, stream));
public static void RecordDeadLetter(string transport, string stream)
=> DeadLetterCounter.Add(1, BuildTags(transport, stream));
private static KeyValuePair<string, object?>[] BuildTags(string transport, string stream)
=> new[]
{
new KeyValuePair<string, object?>(TransportTag, transport),
new KeyValuePair<string, object?>(StreamTag, stream)
};
}

View File

@@ -1,146 +1,146 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public static class NotifyQueueServiceCollectionExtensions
{
public static IServiceCollection AddNotifyEventQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var eventOptions = new NotifyEventQueueOptions();
configuration.GetSection(sectionName).Bind(eventOptions);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(eventOptions);
services.AddSingleton<INotifyEventQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyQueueHealthCheck>();
return services;
}
public static IServiceCollection AddNotifyDeliveryQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:deliveryQueue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var deliveryOptions = new NotifyDeliveryQueueOptions();
configuration.GetSection(sectionName).Bind(deliveryOptions);
services.AddSingleton(deliveryOptions);
services.AddSingleton<INotifyDeliveryQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
ApplyDeliveryFallbacks(opts, eventOpts);
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
builder.AddCheck<NotifyQueueHealthCheck>(
name: "notify-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue" });
return builder;
}
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
name: "notify-delivery-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue", "delivery" });
return builder;
}
private static void ApplyDeliveryFallbacks(
NotifyDeliveryQueueOptions deliveryOptions,
NotifyEventQueueOptions? eventOptions)
{
if (eventOptions is null)
{
return;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
{
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
{
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
}
}
}
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Queue.Nats;
using StellaOps.Notify.Queue.Redis;
namespace StellaOps.Notify.Queue;
public static class NotifyQueueServiceCollectionExtensions
{
public static IServiceCollection AddNotifyEventQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:queue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var eventOptions = new NotifyEventQueueOptions();
configuration.GetSection(sectionName).Bind(eventOptions);
services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(eventOptions);
services.AddSingleton<INotifyEventQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyEventQueueOptions>();
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyEventQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyEventQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyEventQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyEventQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyQueueHealthCheck>();
return services;
}
public static IServiceCollection AddNotifyDeliveryQueue(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "notify:deliveryQueue")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var deliveryOptions = new NotifyDeliveryQueueOptions();
configuration.GetSection(sectionName).Bind(deliveryOptions);
services.AddSingleton(deliveryOptions);
services.AddSingleton<INotifyDeliveryQueue>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
var opts = sp.GetRequiredService<NotifyDeliveryQueueOptions>();
var eventOpts = sp.GetService<NotifyEventQueueOptions>();
ApplyDeliveryFallbacks(opts, eventOpts);
return opts.Transport switch
{
NotifyQueueTransportKind.Redis => new RedisNotifyDeliveryQueue(
opts,
opts.Redis,
loggerFactory.CreateLogger<RedisNotifyDeliveryQueue>(),
timeProvider),
NotifyQueueTransportKind.Nats => new NatsNotifyDeliveryQueue(
opts,
opts.Nats,
loggerFactory.CreateLogger<NatsNotifyDeliveryQueue>(),
timeProvider),
_ => throw new InvalidOperationException($"Unsupported Notify delivery queue transport kind '{opts.Transport}'.")
};
});
services.AddSingleton<NotifyDeliveryQueueHealthCheck>();
return services;
}
public static IHealthChecksBuilder AddNotifyQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyQueueHealthCheck>();
builder.AddCheck<NotifyQueueHealthCheck>(
name: "notify-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue" });
return builder;
}
public static IHealthChecksBuilder AddNotifyDeliveryQueueHealthCheck(
this IHealthChecksBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddSingleton<NotifyDeliveryQueueHealthCheck>();
builder.AddCheck<NotifyDeliveryQueueHealthCheck>(
name: "notify-delivery-queue",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "notify", "queue", "delivery" });
return builder;
}
private static void ApplyDeliveryFallbacks(
NotifyDeliveryQueueOptions deliveryOptions,
NotifyEventQueueOptions? eventOptions)
{
if (eventOptions is null)
{
return;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Redis.ConnectionString))
{
deliveryOptions.Redis.ConnectionString = eventOptions.Redis.ConnectionString;
deliveryOptions.Redis.Database ??= eventOptions.Redis.Database;
}
if (string.IsNullOrWhiteSpace(deliveryOptions.Nats.Url))
{
deliveryOptions.Nats.Url = eventOptions.Nats.Url;
}
}
}

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Notify.Queue;
/// <summary>
/// Supported transports for the Notify event queue.
/// </summary>
public enum NotifyQueueTransportKind
{
Redis,
Nats
}
namespace StellaOps.Notify.Queue;
/// <summary>
/// Supported transports for the Notify event queue.
/// </summary>
public enum NotifyQueueTransportKind
{
Redis,
Nats
}

View File

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

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly RedisNotifyDeliveryQueue _queue;
private int _completed;
internal RedisNotifyDeliveryLease(
RedisNotifyDeliveryQueue queue,
string messageId,
NotifyDeliveryQueueMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string partitionKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
PartitionKey = partitionKey ?? message.ChannelId;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string PartitionKey { get; }
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryLease : INotifyQueueLease<NotifyDeliveryQueueMessage>
{
private readonly RedisNotifyDeliveryQueue _queue;
private int _completed;
internal RedisNotifyDeliveryLease(
RedisNotifyDeliveryQueue queue,
string messageId,
NotifyDeliveryQueueMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string partitionKey)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
IdempotencyKey = idempotencyKey ?? message.IdempotencyKey;
PartitionKey = partitionKey ?? message.ChannelId;
}
public string MessageId { get; }
public int Attempt { get; internal set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => Message.Stream;
public string TenantId => Message.TenantId;
public string PartitionKey { get; }
public string IdempotencyKey { get; }
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyDeliveryQueueMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,76 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly RedisNotifyEventQueue _queue;
private int _completed;
internal RedisNotifyEventLease(
RedisNotifyEventQueue queue,
NotifyRedisEventStreamOptions streamOptions,
string messageId,
NotifyQueueEventMessage message,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
internal NotifyRedisEventStreamOptions StreamOptions { get; }
public string MessageId { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => StreamOptions.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyEventLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly RedisNotifyEventQueue _queue;
private int _completed;
internal RedisNotifyEventLease(
RedisNotifyEventQueue queue,
NotifyRedisEventStreamOptions streamOptions,
string messageId,
NotifyQueueEventMessage message,
int attempt,
string consumer,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
StreamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
MessageId = messageId ?? throw new ArgumentNullException(nameof(messageId));
Message = message ?? throw new ArgumentNullException(nameof(message));
Attempt = attempt;
Consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
}
internal NotifyRedisEventStreamOptions StreamOptions { get; }
public string MessageId { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string Stream => StreamOptions.Stream;
public string TenantId => Message.TenantId;
public string? PartitionKey => Message.PartitionKey;
public string IdempotencyKey => Message.IdempotencyKey;
public string? TraceId => Message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => Message.Attributes;
public NotifyQueueEventMessage Message { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
}

View File

@@ -1,6 +1,6 @@
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.InMemory.Documents;
/// <summary>
/// Represents a notification channel document (MongoDB compatibility shim).

View File

@@ -1,6 +1,6 @@
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.InMemory.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.InMemory.Repositories;
/// <summary>
/// Repository interface for notification channels (MongoDB compatibility shim).

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.InMemory.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.InMemory.Repositories;
/// <summary>
/// In-memory implementation of channel repository for development/testing.

View File

@@ -1,24 +1,24 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notify.Storage.InMemory.Repositories;
using StellaOps.Notify.Storage.Postgres;
namespace StellaOps.Notify.Storage.Mongo;
namespace StellaOps.Notify.Storage.InMemory;
/// <summary>
/// Extension methods for configuring Notify MongoDB compatibility shim.
/// This shim delegates to PostgreSQL storage while maintaining the MongoDB interface.
/// Extension methods for configuring Notify in-memory storage.
/// This implementation delegates to PostgreSQL storage while maintaining the repository interface.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Notify MongoDB compatibility storage services.
/// Adds Notify in-memory storage services.
/// Internally delegates to PostgreSQL storage.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration section for storage options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddNotifyMongoStorage(
public static IServiceCollection AddNotifyInMemoryStorage(
this IServiceCollection services,
IConfigurationSection configuration)
{
@@ -33,7 +33,7 @@ public static class ServiceCollectionExtensions
// Register the underlying Postgres storage
services.AddNotifyPostgresStorageInternal(configuration);
// Register MongoDB-compatible repository adapters
// Register in-memory repository adapters
services.AddScoped<INotifyChannelRepository, NotifyChannelRepositoryAdapter>();
services.AddScoped<INotifyRuleRepository, NotifyRuleRepositoryAdapter>();
services.AddScoped<INotifyTemplateRepository, NotifyTemplateRepositoryAdapter>();

View File

@@ -6,8 +6,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Notify.Storage.Mongo</RootNamespace>
<Description>MongoDB compatibility shim for Notify storage - delegates to PostgreSQL storage</Description>
<RootNamespace>StellaOps.Notify.Storage.InMemory</RootNamespace>
<Description>In-memory storage implementation for Notify - delegates to PostgreSQL storage</Description>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,16 +1,16 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace StellaOps.Notify.Storage.Mongo;
namespace StellaOps.Notify.Storage.InMemory;
/// <summary>
/// Hosted service for MongoDB initialization (compatibility shim - no-op).
/// Hosted service for storage initialization (compatibility shim - no-op).
/// </summary>
public sealed class MongoInitializationHostedService : IHostedService
public sealed class StorageInitializationHostedService : IHostedService
{
private readonly ILogger<MongoInitializationHostedService> _logger;
private readonly ILogger<StorageInitializationHostedService> _logger;
public MongoInitializationHostedService(ILogger<MongoInitializationHostedService> logger)
public StorageInitializationHostedService(ILogger<StorageInitializationHostedService> logger)
{
_logger = logger;
}

View File

@@ -1,100 +1,100 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Email.Tests;
public sealed class EmailChannelHealthProviderTests
{
private static readonly EmailChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 15, 0, 0, TimeSpan.Zero),
"trace-email-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["email.channel.enabled"]);
Assert.Equal("true", result.Metadata["email.validation.targetPresent"]);
Assert.Equal("ops@example.com", result.Metadata["email.target"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-email-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["email.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: null,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com"
}),
enabled: true);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-email-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["email.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: target,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com",
["password"] = "super-secret"
}),
enabled: enabled);
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Email.Tests;
public sealed class EmailChannelHealthProviderTests
{
private static readonly EmailChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 15, 0, 0, TimeSpan.Zero),
"trace-email-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["email.channel.enabled"]);
Assert.Equal("true", result.Metadata["email.validation.targetPresent"]);
Assert.Equal("ops@example.com", result.Metadata["email.target"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-email-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["email.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: null,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com"
}),
enabled: true);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-email-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["email.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: target,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com",
["password"] = "super-secret"
}),
enabled: enabled);
}
}

View File

@@ -1,96 +1,96 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelHealthProviderTests
{
private static readonly SlackChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 14, 0, 0, TimeSpan.Zero),
"trace-slack-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["slack.channel.enabled"]);
Assert.Equal("true", result.Metadata["slack.validation.targetPresent"]);
Assert.Equal("#sec-ops", result.Metadata["slack.channel"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-slack-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["slack.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, target: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-slack-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["slack.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: target,
properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelHealthProviderTests
{
private static readonly SlackChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 14, 0, 0, TimeSpan.Zero),
"trace-slack-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["slack.channel.enabled"]);
Assert.Equal("true", result.Metadata["slack.validation.targetPresent"]);
Assert.Equal("#sec-ops", result.Metadata["slack.channel"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-slack-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["slack.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, target: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-slack-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["slack.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: target,
properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}

View File

@@ -1,113 +1,113 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelTestProviderTests
{
private static readonly ChannelTestPreviewRequest EmptyRequest = new(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: null,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
[Fact]
public async Task BuildPreviewAsync_ProducesDeterministicMetadata()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: new DateTimeOffset(2025, 10, 20, 12, 00, 00, TimeSpan.Zero),
TraceId: "trace-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("slack", result.Preview.ChannelType.ToString().ToLowerInvariant());
Assert.Equal(channel.Config.Target, result.Preview.Target);
Assert.Equal("chat:write,chat:write.public", result.Metadata["slack.scopes.required"]);
Assert.Equal("stellaops-sec", result.Metadata["slack.config.workspace"]);
var redactedToken = result.Metadata["slack.config.botToken"];
Assert.DoesNotContain("abcdefghijklmnop", redactedToken);
Assert.StartsWith("xoxb-", redactedToken);
Assert.EndsWith("mnop", redactedToken);
using var parsed = JsonDocument.Parse(result.Preview.Body);
var contextText = parsed.RootElement
.GetProperty("blocks")[1]
.GetProperty("elements")[0]
.GetProperty("text")
.GetString();
Assert.NotNull(contextText);
Assert.Contains("trace-001", contextText);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task BuildPreviewAsync_RedactsSensitiveProperties()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["SigningSecret"] = "whsec_super-secret-value",
["apiToken"] = "xoxs-000000000000-super",
["endpoint"] = "https://hooks.slack.com/services/T000/B000/AAA"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: DateTimeOffset.UtcNow,
TraceId: "trace-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("***", result.Metadata["slack.config.SigningSecret"]);
Assert.DoesNotContain("xoxs-000000000000-super", result.Metadata["slack.config.apiToken"]);
Assert.Equal("https://hooks.slack.com/services/T000/B000/AAA", result.Metadata["slack.config.endpoint"]);
}
private static NotifyChannel CreateChannel(IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: "#sec-ops",
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = sha.ComputeHash(bytes);
return System.Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelTestProviderTests
{
private static readonly ChannelTestPreviewRequest EmptyRequest = new(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: null,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
[Fact]
public async Task BuildPreviewAsync_ProducesDeterministicMetadata()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: new DateTimeOffset(2025, 10, 20, 12, 00, 00, TimeSpan.Zero),
TraceId: "trace-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("slack", result.Preview.ChannelType.ToString().ToLowerInvariant());
Assert.Equal(channel.Config.Target, result.Preview.Target);
Assert.Equal("chat:write,chat:write.public", result.Metadata["slack.scopes.required"]);
Assert.Equal("stellaops-sec", result.Metadata["slack.config.workspace"]);
var redactedToken = result.Metadata["slack.config.botToken"];
Assert.DoesNotContain("abcdefghijklmnop", redactedToken);
Assert.StartsWith("xoxb-", redactedToken);
Assert.EndsWith("mnop", redactedToken);
using var parsed = JsonDocument.Parse(result.Preview.Body);
var contextText = parsed.RootElement
.GetProperty("blocks")[1]
.GetProperty("elements")[0]
.GetProperty("text")
.GetString();
Assert.NotNull(contextText);
Assert.Contains("trace-001", contextText);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task BuildPreviewAsync_RedactsSensitiveProperties()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["SigningSecret"] = "whsec_super-secret-value",
["apiToken"] = "xoxs-000000000000-super",
["endpoint"] = "https://hooks.slack.com/services/T000/B000/AAA"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: DateTimeOffset.UtcNow,
TraceId: "trace-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("***", result.Metadata["slack.config.SigningSecret"]);
Assert.DoesNotContain("xoxs-000000000000-super", result.Metadata["slack.config.apiToken"]);
Assert.Equal("https://hooks.slack.com/services/T000/B000/AAA", result.Metadata["slack.config.endpoint"]);
}
private static NotifyChannel CreateChannel(IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: "#sec-ops",
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = sha.ComputeHash(bytes);
return System.Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
}

View File

@@ -1,98 +1,98 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelHealthProviderTests
{
private static readonly TeamsChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthyWithMetadata()
{
var channel = CreateChannel(enabled: true, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
new DateTimeOffset(2025, 10, 20, 12, 0, 0, TimeSpan.Zero),
"trace-health-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("Teams channel configuration validated.", result.Message);
Assert.Equal("true", result.Metadata["teams.channel.enabled"]);
Assert.Equal("true", result.Metadata["teams.validation.targetPresent"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
DateTimeOffset.UtcNow,
"trace-health-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["teams.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, endpoint: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-health-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["teams.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? endpoint)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: new Dictionary<string, string>
{
["tenant"] = "contoso.onmicrosoft.com",
["webhookKey"] = "abcdef0123456789"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelHealthProviderTests
{
private static readonly TeamsChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthyWithMetadata()
{
var channel = CreateChannel(enabled: true, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
new DateTimeOffset(2025, 10, 20, 12, 0, 0, TimeSpan.Zero),
"trace-health-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("Teams channel configuration validated.", result.Message);
Assert.Equal("true", result.Metadata["teams.channel.enabled"]);
Assert.Equal("true", result.Metadata["teams.validation.targetPresent"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
DateTimeOffset.UtcNow,
"trace-health-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["teams.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, endpoint: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-health-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["teams.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? endpoint)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: new Dictionary<string, string>
{
["tenant"] = "contoso.onmicrosoft.com",
["webhookKey"] = "abcdef0123456789"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}

View File

@@ -1,135 +1,135 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelTestProviderTests
{
[Fact]
public async Task BuildPreviewAsync_EmitsFallbackMetadata()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>
{
["team"] = "secops",
["webhookKey"] = "s3cr3t-value-with-key-fragment",
["tenant"] = "contoso.onmicrosoft.com"
});
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: "Notify Critical Finding",
Summary: "Critical container vulnerability detected.",
Body: "CVSS 9.8 vulnerability detected in ubuntu:22.04 base layer.",
TextBody: null,
Locale: "en-US",
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
new DateTimeOffset(2025, 10, 20, 10, 0, 0, TimeSpan.Zero),
TraceId: "trace-teams-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal(NotifyChannelType.Teams, result.Preview.ChannelType);
Assert.Equal(channel.Config.Endpoint, result.Preview.Target);
Assert.Equal("Critical container vulnerability detected.", result.Preview.Summary);
Assert.NotNull(result.Metadata);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal("1.5", result.Metadata["teams.card.version"]);
var fallback = result.Metadata["teams.fallbackText"];
Assert.Equal(result.Preview.TextBody, fallback);
Assert.Equal("Critical container vulnerability detected.", fallback);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
Assert.Equal("***", result.Metadata["teams.config.webhookKey"]);
Assert.Equal("contoso.onmicrosoft.com", result.Metadata["teams.config.tenant"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.config.endpoint"]);
using var payload = JsonDocument.Parse(result.Preview.Body);
Assert.Equal("message", payload.RootElement.GetProperty("type").GetString());
Assert.Equal(result.Preview.TextBody, payload.RootElement.GetProperty("text").GetString());
Assert.Equal(result.Preview.Summary, payload.RootElement.GetProperty("summary").GetString());
var attachments = payload.RootElement.GetProperty("attachments");
Assert.True(attachments.GetArrayLength() > 0);
Assert.Equal(
"AdaptiveCard",
attachments[0].GetProperty("content").GetProperty("type").GetString());
}
[Fact]
public async Task BuildPreviewAsync_TruncatesLongFallback()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>());
var longText = new string('A', 600);
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: longText,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
DateTimeOffset.UtcNow,
TraceId: "trace-teams-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(result.Metadata);
var fallback = Assert.IsType<string>(result.Preview.TextBody);
Assert.Equal(512, fallback.Length);
Assert.Equal(fallback, metadata["teams.fallbackText"]);
Assert.StartsWith(new string('A', 512), fallback);
}
private static NotifyChannel CreateChannel(string endpoint, IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelTestProviderTests
{
[Fact]
public async Task BuildPreviewAsync_EmitsFallbackMetadata()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>
{
["team"] = "secops",
["webhookKey"] = "s3cr3t-value-with-key-fragment",
["tenant"] = "contoso.onmicrosoft.com"
});
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: "Notify Critical Finding",
Summary: "Critical container vulnerability detected.",
Body: "CVSS 9.8 vulnerability detected in ubuntu:22.04 base layer.",
TextBody: null,
Locale: "en-US",
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
new DateTimeOffset(2025, 10, 20, 10, 0, 0, TimeSpan.Zero),
TraceId: "trace-teams-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal(NotifyChannelType.Teams, result.Preview.ChannelType);
Assert.Equal(channel.Config.Endpoint, result.Preview.Target);
Assert.Equal("Critical container vulnerability detected.", result.Preview.Summary);
Assert.NotNull(result.Metadata);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal("1.5", result.Metadata["teams.card.version"]);
var fallback = result.Metadata["teams.fallbackText"];
Assert.Equal(result.Preview.TextBody, fallback);
Assert.Equal("Critical container vulnerability detected.", fallback);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
Assert.Equal("***", result.Metadata["teams.config.webhookKey"]);
Assert.Equal("contoso.onmicrosoft.com", result.Metadata["teams.config.tenant"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.config.endpoint"]);
using var payload = JsonDocument.Parse(result.Preview.Body);
Assert.Equal("message", payload.RootElement.GetProperty("type").GetString());
Assert.Equal(result.Preview.TextBody, payload.RootElement.GetProperty("text").GetString());
Assert.Equal(result.Preview.Summary, payload.RootElement.GetProperty("summary").GetString());
var attachments = payload.RootElement.GetProperty("attachments");
Assert.True(attachments.GetArrayLength() > 0);
Assert.Equal(
"AdaptiveCard",
attachments[0].GetProperty("content").GetProperty("type").GetString());
}
[Fact]
public async Task BuildPreviewAsync_TruncatesLongFallback()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>());
var longText = new string('A', 600);
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: longText,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
DateTimeOffset.UtcNow,
TraceId: "trace-teams-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(result.Metadata);
var fallback = Assert.IsType<string>(result.Preview.TextBody);
Assert.Equal(512, fallback.Length);
Assert.Equal(fallback, metadata["teams.fallbackText"]);
Assert.StartsWith(new string('A', 512), fallback);
}
private static NotifyChannel CreateChannel(string endpoint, IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}

View File

@@ -1,47 +1,47 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class DocSampleTests
{
[Theory]
[InlineData("notify-rule@1.sample.json")]
[InlineData("notify-channel@1.sample.json")]
[InlineData("notify-template@1.sample.json")]
[InlineData("notify-event@1.sample.json")]
public void CanonicalSamplesStayInSync(string fileName)
{
var json = LoadSample(fileName);
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
string canonical = fileName switch
{
"notify-rule@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeRule(node)),
"notify-channel@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeChannel(node)),
"notify-template@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeTemplate(node)),
"notify-event@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json)),
_ => throw new ArgumentOutOfRangeException(nameof(fileName), fileName, "Unsupported sample.")
};
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
if (!JsonNode.DeepEquals(node, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class DocSampleTests
{
[Theory]
[InlineData("notify-rule@1.sample.json")]
[InlineData("notify-channel@1.sample.json")]
[InlineData("notify-template@1.sample.json")]
[InlineData("notify-event@1.sample.json")]
public void CanonicalSamplesStayInSync(string fileName)
{
var json = LoadSample(fileName);
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
string canonical = fileName switch
{
"notify-rule@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeRule(node)),
"notify-channel@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeChannel(node)),
"notify-template@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeTemplate(node)),
"notify-event@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json)),
_ => throw new ArgumentOutOfRangeException(nameof(fileName), fileName, "Unsupported sample.")
};
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
if (!JsonNode.DeepEquals(node, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,77 +1,77 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyCanonicalJsonSerializerTests
{
[Fact]
public void SerializeRuleIsDeterministic()
{
var ruleA = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec"),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc")
},
metadata: new Dictionary<string, string>
{
["beta"] = "2",
["alpha"] = "1"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var ruleB = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "a", channel: "email:soc"),
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec")
},
metadata: new Dictionary<string, string>
{
["alpha"] = "1",
["beta"] = "2"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var jsonA = NotifyCanonicalJsonSerializer.Serialize(ruleA);
var jsonB = NotifyCanonicalJsonSerializer.Serialize(ruleB);
Assert.Equal(jsonA, jsonB);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", jsonA, StringComparison.Ordinal);
}
[Fact]
public void SerializeEventOrdersPayloadKeys()
{
var payload = JsonNode.Parse("{\"b\":2,\"a\":1}");
var @event = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
tenant: "tenant-a",
ts: DateTimeOffset.Parse("2025-10-18T05:41:22Z"),
payload: payload,
scope: NotifyEventScope.Create(repo: "ghcr.io/acme/api", digest: "sha256:123"));
var json = NotifyCanonicalJsonSerializer.Serialize(@event);
var payloadIndex = json.IndexOf("\"payload\":{", StringComparison.Ordinal);
Assert.NotEqual(-1, payloadIndex);
var aIndex = json.IndexOf("\"a\":1", payloadIndex, StringComparison.Ordinal);
var bIndex = json.IndexOf("\"b\":2", payloadIndex, StringComparison.Ordinal);
Assert.True(aIndex is >= 0 && bIndex is >= 0 && aIndex < bIndex, "Payload keys should be ordered alphabetically.");
}
}
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyCanonicalJsonSerializerTests
{
[Fact]
public void SerializeRuleIsDeterministic()
{
var ruleA = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec"),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc")
},
metadata: new Dictionary<string, string>
{
["beta"] = "2",
["alpha"] = "1"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var ruleB = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "a", channel: "email:soc"),
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec")
},
metadata: new Dictionary<string, string>
{
["alpha"] = "1",
["beta"] = "2"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var jsonA = NotifyCanonicalJsonSerializer.Serialize(ruleA);
var jsonB = NotifyCanonicalJsonSerializer.Serialize(ruleB);
Assert.Equal(jsonA, jsonB);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", jsonA, StringComparison.Ordinal);
}
[Fact]
public void SerializeEventOrdersPayloadKeys()
{
var payload = JsonNode.Parse("{\"b\":2,\"a\":1}");
var @event = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
tenant: "tenant-a",
ts: DateTimeOffset.Parse("2025-10-18T05:41:22Z"),
payload: payload,
scope: NotifyEventScope.Create(repo: "ghcr.io/acme/api", digest: "sha256:123"));
var json = NotifyCanonicalJsonSerializer.Serialize(@event);
var payloadIndex = json.IndexOf("\"payload\":{", StringComparison.Ordinal);
Assert.NotEqual(-1, payloadIndex);
var aIndex = json.IndexOf("\"a\":1", payloadIndex, StringComparison.Ordinal);
var bIndex = json.IndexOf("\"b\":2", payloadIndex, StringComparison.Ordinal);
Assert.True(aIndex is >= 0 && bIndex is >= 0 && aIndex < bIndex, "Payload keys should be ordered alphabetically.");
}
}

View File

@@ -1,46 +1,46 @@
using System;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyDeliveryTests
{
[Fact]
public void AttemptsAreSortedChronologically()
{
var attempts = new[]
{
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:25:00Z"), NotifyDeliveryAttemptStatus.Succeeded),
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:15:00Z"), NotifyDeliveryAttemptStatus.Sending),
};
var delivery = NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
attempts: attempts);
Assert.Collection(
delivery.Attempts,
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Sending, attempt.Status),
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Succeeded, attempt.Status));
}
[Fact]
public void RenderedNormalizesAttachments()
{
var rendered = NotifyDeliveryRendered.Create(
channelType: NotifyChannelType.Slack,
format: NotifyDeliveryFormat.Slack,
target: "#sec",
title: "Alert",
body: "Body",
attachments: new[] { "B", "a", "a" });
Assert.Equal(new[] { "B", "a" }.OrderBy(x => x, StringComparer.Ordinal), rendered.Attachments);
}
}
using System;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyDeliveryTests
{
[Fact]
public void AttemptsAreSortedChronologically()
{
var attempts = new[]
{
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:25:00Z"), NotifyDeliveryAttemptStatus.Succeeded),
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:15:00Z"), NotifyDeliveryAttemptStatus.Sending),
};
var delivery = NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
attempts: attempts);
Assert.Collection(
delivery.Attempts,
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Sending, attempt.Status),
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Succeeded, attempt.Status));
}
[Fact]
public void RenderedNormalizesAttachments()
{
var rendered = NotifyDeliveryRendered.Create(
channelType: NotifyChannelType.Slack,
format: NotifyDeliveryFormat.Slack,
target: "#sec",
title: "Alert",
body: "Body",
attachments: new[] { "B", "a", "a" });
Assert.Equal(new[] { "B", "a" }.OrderBy(x => x, StringComparer.Ordinal), rendered.Attachments);
}
}

View File

@@ -1,63 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyRuleTests
{
[Fact]
public void ConstructorThrowsWhenActionsMissing()
{
var match = NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady });
var exception = Assert.Throws<ArgumentException>(() =>
NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: match,
actions: Array.Empty<NotifyRuleAction>()));
Assert.Contains("At least one action is required", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ConstructorNormalizesCollections()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "Zastava.Admission", NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec-alerts", throttle: TimeSpan.FromMinutes(5)),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc", metadata: new Dictionary<string, string>
{
[" locale "] = " EN-us "
})
},
labels: new Dictionary<string, string>
{
[" team "] = " SecOps "
},
metadata: new Dictionary<string, string>
{
["source"] = "tests"
});
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal(new[] { "scanner.report.ready", "zastava.admission" }, rule.Match.EventKinds);
Assert.Equal(new[] { "a", "b" }, rule.Actions.Select(action => action.ActionId));
Assert.Equal(TimeSpan.FromMinutes(5), rule.Actions.Last().Throttle);
Assert.Equal("secops", rule.Labels.Single().Value.ToLowerInvariant());
Assert.Equal("en-us", rule.Actions.First().Metadata["locale"].ToLowerInvariant());
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", json, StringComparison.Ordinal);
Assert.Contains("\"actions\":[{\"actionId\":\"a\"", json, StringComparison.Ordinal);
Assert.Contains("\"throttle\":\"PT5M\"", json, StringComparison.Ordinal);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyRuleTests
{
[Fact]
public void ConstructorThrowsWhenActionsMissing()
{
var match = NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady });
var exception = Assert.Throws<ArgumentException>(() =>
NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: match,
actions: Array.Empty<NotifyRuleAction>()));
Assert.Contains("At least one action is required", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ConstructorNormalizesCollections()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "Zastava.Admission", NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec-alerts", throttle: TimeSpan.FromMinutes(5)),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc", metadata: new Dictionary<string, string>
{
[" locale "] = " EN-us "
})
},
labels: new Dictionary<string, string>
{
[" team "] = " SecOps "
},
metadata: new Dictionary<string, string>
{
["source"] = "tests"
});
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal(new[] { "scanner.report.ready", "zastava.admission" }, rule.Match.EventKinds);
Assert.Equal(new[] { "a", "b" }, rule.Actions.Select(action => action.ActionId));
Assert.Equal(TimeSpan.FromMinutes(5), rule.Actions.Last().Throttle);
Assert.Equal("secops", rule.Labels.Single().Value.ToLowerInvariant());
Assert.Equal("en-us", rule.Actions.First().Metadata["locale"].ToLowerInvariant());
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", json, StringComparison.Ordinal);
Assert.Contains("\"actions\":[{\"actionId\":\"a\"", json, StringComparison.Ordinal);
Assert.Contains("\"throttle\":\"PT5M\"", json, StringComparison.Ordinal);
}
}

View File

@@ -1,101 +1,101 @@
using System;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifySchemaMigrationTests
{
[Fact]
public void UpgradeRuleAddsSchemaVersionWhenMissing()
{
var json = JsonNode.Parse(
"""
{
"ruleId": "rule-legacy",
"tenantId": "tenant-1",
"name": "legacy",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:legacy", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var rule = NotifySchemaMigration.UpgradeRule(json);
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal("rule-legacy", rule.RuleId);
}
[Fact]
public void UpgradeRuleThrowsOnUnknownSchema()
{
var json = JsonNode.Parse(
"""
{
"schemaVersion": "notify.rule@2",
"ruleId": "rule-future",
"tenantId": "tenant-1",
"name": "future",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:soc", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var exception = Assert.Throws<NotSupportedException>(() => NotifySchemaMigration.UpgradeRule(json));
Assert.Contains("notify rule schema version", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void UpgradeChannelDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"channelId": "channel-email",
"tenantId": "tenant-1",
"name": "email:soc",
"type": "email",
"config": { "secretRef": "ref://notify/channels/email/soc" },
"enabled": true,
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var channel = NotifySchemaMigration.UpgradeChannel(json);
Assert.Equal(NotifySchemaVersions.Channel, channel.SchemaVersion);
Assert.Equal("channel-email", channel.ChannelId);
}
[Fact]
public void UpgradeTemplateDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"templateId": "tmpl-slack-concise",
"tenantId": "tenant-1",
"channelType": "slack",
"key": "concise",
"locale": "en-us",
"body": "{{summary}}",
"renderMode": "markdown",
"format": "slack",
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var template = NotifySchemaMigration.UpgradeTemplate(json);
Assert.Equal(NotifySchemaVersions.Template, template.SchemaVersion);
Assert.Equal("tmpl-slack-concise", template.TemplateId);
}
}
using System;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifySchemaMigrationTests
{
[Fact]
public void UpgradeRuleAddsSchemaVersionWhenMissing()
{
var json = JsonNode.Parse(
"""
{
"ruleId": "rule-legacy",
"tenantId": "tenant-1",
"name": "legacy",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:legacy", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var rule = NotifySchemaMigration.UpgradeRule(json);
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal("rule-legacy", rule.RuleId);
}
[Fact]
public void UpgradeRuleThrowsOnUnknownSchema()
{
var json = JsonNode.Parse(
"""
{
"schemaVersion": "notify.rule@2",
"ruleId": "rule-future",
"tenantId": "tenant-1",
"name": "future",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:soc", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var exception = Assert.Throws<NotSupportedException>(() => NotifySchemaMigration.UpgradeRule(json));
Assert.Contains("notify rule schema version", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void UpgradeChannelDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"channelId": "channel-email",
"tenantId": "tenant-1",
"name": "email:soc",
"type": "email",
"config": { "secretRef": "ref://notify/channels/email/soc" },
"enabled": true,
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var channel = NotifySchemaMigration.UpgradeChannel(json);
Assert.Equal(NotifySchemaVersions.Channel, channel.SchemaVersion);
Assert.Equal("channel-email", channel.ChannelId);
}
[Fact]
public void UpgradeTemplateDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"templateId": "tmpl-slack-concise",
"tenantId": "tenant-1",
"channelType": "slack",
"key": "concise",
"locale": "en-us",
"body": "{{summary}}",
"renderMode": "markdown",
"format": "slack",
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var template = NotifySchemaMigration.UpgradeTemplate(json);
Assert.Equal(NotifySchemaVersions.Template, template.SchemaVersion);
Assert.Equal("tmpl-slack-concise", template.TemplateId);
}
}

View File

@@ -1,17 +1,17 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class PlatformEventSamplesTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Theory]
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class PlatformEventSamplesTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Theory]
[InlineData("scanner.report.ready@1.sample.json", NotifyEventKinds.ScannerReportReady)]
[InlineData("scanner.scan.completed@1.sample.json", NotifyEventKinds.ScannerScanCompleted)]
[InlineData("scheduler.rescan.delta@1.sample.json", NotifyEventKinds.SchedulerRescanDelta)]
@@ -20,36 +20,36 @@ public sealed class PlatformEventSamplesTests
[InlineData("airgap-bundle-import@1.sample.json", NotifyEventKinds.AirgapBundleImport)]
[InlineData("airgap-portable-export-completed@1.sample.json", NotifyEventKinds.AirgapPortableExportCompleted)]
public void PlatformEventSamplesRoundtripThroughNotifySerializer(string fileName, string expectedKind)
{
var json = LoadSample(fileName);
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
Assert.NotNull(notifyEvent);
Assert.Equal(expectedKind, notifyEvent!.Kind);
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
Assert.False(string.IsNullOrWhiteSpace(notifyEvent.Tenant));
Assert.Equal(TimeSpan.Zero, notifyEvent.Ts.Offset);
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
if (!JsonNode.DeepEquals(sampleNode, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = sampleNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new Xunit.Sdk.XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}
{
var json = LoadSample(fileName);
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
Assert.NotNull(notifyEvent);
Assert.Equal(expectedKind, notifyEvent!.Kind);
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
Assert.False(string.IsNullOrWhiteSpace(notifyEvent.Tenant));
Assert.Equal(TimeSpan.Zero, notifyEvent.Ts.Offset);
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
if (!JsonNode.DeepEquals(sampleNode, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = sampleNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new Xunit.Sdk.XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,223 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery("tenant-a");
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "chan-a",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldReschedule()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-dead",
channelType: NotifyChannelType.Webhook));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(200);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
var consumerConfig = new ConsumerConfig
{
DurableName = "notify-delivery-dead-test",
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
{
dlqMsg = msg;
await msg.AckAsync(new AckOpts());
break;
}
dlqMsg.Should().NotBeNull();
}
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new NatsNotifyDeliveryQueue(
options,
options.Nats,
NullLogger<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(20),
RetryMaxBackoff = TimeSpan.FromMilliseconds(200),
Nats = new NotifyNatsDeliveryQueueOptions
{
Url = url,
Stream = "NOTIFY_DELIVERY_TEST",
Subject = "notify.delivery.test",
DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD",
DeadLetterSubject = "notify.delivery.test.dead",
DurableConsumer = "notify-delivery-tests",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1")
{
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: tenantId,
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: DateTimeOffset.UtcNow);
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery("tenant-a");
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "chan-a",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldReschedule()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-dead",
channelType: NotifyChannelType.Webhook));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(200);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
var consumerConfig = new ConsumerConfig
{
DurableName = "notify-delivery-dead-test",
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
{
dlqMsg = msg;
await msg.AckAsync(new AckOpts());
break;
}
dlqMsg.Should().NotBeNull();
}
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new NatsNotifyDeliveryQueue(
options,
options.Nats,
NullLogger<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(20),
RetryMaxBackoff = TimeSpan.FromMilliseconds(200),
Nats = new NotifyNatsDeliveryQueueOptions
{
Url = url,
Stream = "NOTIFY_DELIVERY_TEST",
Subject = "notify.delivery.test",
DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD",
DeadLetterSubject = "notify.delivery.test.dead",
DurableConsumer = "notify-delivery-tests",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1")
{
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: tenantId,
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: DateTimeOffset.UtcNow);
}
}
}

View File

@@ -1,225 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-a");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-1");
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-xyz",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().BeGreaterThanOrEqualTo(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-xyz");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var first = TestData.CreateEvent();
var second = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
leases.Should().HaveCount(2);
leases.Select(x => x.Message.Event.EventId)
.Should()
.ContainInOrder(first.EventId, second.EventId);
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
leases.Should().ContainSingle();
await Task.Delay(200);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new NatsNotifyEventQueue(
options,
options.Nats,
NullLogger<NatsNotifyEventQueue>.Instance,
TimeProvider.System);
}
private NotifyEventQueueOptions CreateOptions()
{
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(50),
RetryMaxBackoff = TimeSpan.FromSeconds(1),
Nats = new NotifyNatsEventQueueOptions
{
Url = connectionUrl,
Stream = "NOTIFY_TEST",
Subject = "notify.test.events",
DeadLetterStream = "NOTIFY_TEST_DEAD",
DeadLetterSubject = "notify.test.events.dead",
DurableConsumer = "notify-test-consumer",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
}
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-a");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-1");
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-xyz",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().BeGreaterThanOrEqualTo(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-xyz");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var first = TestData.CreateEvent();
var second = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
leases.Should().HaveCount(2);
leases.Select(x => x.Message.Event.EventId)
.Should()
.ContainInOrder(first.EventId, second.EventId);
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
leases.Should().ContainSingle();
await Task.Delay(200);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new NatsNotifyEventQueue(
options,
options.Nats,
NullLogger<NatsNotifyEventQueue>.Instance,
TimeProvider.System);
}
private NotifyEventQueueOptions CreateOptions()
{
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(50),
RetryMaxBackoff = TimeSpan.FromSeconds(1),
Nats = new NotifyNatsEventQueueOptions
{
Url = connectionUrl,
Stream = "NOTIFY_TEST",
Subject = "notify.test.events",
DeadLetterStream = "NOTIFY_TEST_DEAD",
DeadLetterSubject = "notify.test.events.dead",
DurableConsumer = "notify-test-consumer",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
}
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}

View File

@@ -1,197 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyDeliveryQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery();
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "channel-1",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldRescheduleDelivery()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
retried.Attempt.Should().Be(2);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-dead",
channelType: NotifyChannelType.Email));
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(100);
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
var db = mux.GetDatabase();
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
deadLetters.Should().NotBeEmpty();
}
private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new RedisNotifyDeliveryQueue(
options,
options.Redis,
NullLogger<RedisNotifyDeliveryQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(1),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
Redis = new NotifyRedisDeliveryQueueOptions
{
ConnectionString = _redis.ConnectionString,
StreamName = "notify:deliveries:test",
ConsumerGroup = "notify-delivery-tests",
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery()
{
var now = DateTimeOffset.UtcNow;
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: "tenant-1",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: now,
metadata: new Dictionary<string, string>
{
["integration"] = "tests"
});
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyDeliveryQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery();
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "channel-1",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldRescheduleDelivery()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
retried.Attempt.Should().Be(2);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-dead",
channelType: NotifyChannelType.Email));
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(100);
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
var db = mux.GetDatabase();
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
deadLetters.Should().NotBeEmpty();
}
private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new RedisNotifyDeliveryQueue(
options,
options.Redis,
NullLogger<RedisNotifyDeliveryQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(1),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
Redis = new NotifyRedisDeliveryQueueOptions
{
ConnectionString = _redis.ConnectionString,
StreamName = "notify:deliveries:test",
ConsumerGroup = "notify-delivery-tests",
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery()
{
var now = DateTimeOffset.UtcNow;
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: "tenant-1",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: now,
metadata: new Dictionary<string, string>
{
["integration"] = "tests"
});
}
}
}

View File

@@ -1,220 +1,220 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyEventQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Redis.Streams[0].Stream,
traceId: "trace-123",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().Be(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-123");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var stream = options.Redis.Streams[0].Stream;
var firstEvent = TestData.CreateEvent();
var secondEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
leases.Should().HaveCount(2);
leases.Select(l => l.Message.Event.EventId)
.Should()
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.EventId });
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
leases.Should().ContainSingle();
// Ensure the message has been pending long enough for claim.
await Task.Delay(50);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new RedisNotifyEventQueue(
options,
options.Redis,
NullLogger<RedisNotifyEventQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyEventQueueOptions CreateOptions()
{
var streamOptions = new NotifyRedisEventStreamOptions
{
Stream = "notify:test:events",
ConsumerGroup = "notify-test-consumers",
IdempotencyKeyPrefix = "notify:test:idemp:",
ApproximateMaxLength = 1024
};
var redisOptions = new NotifyRedisEventQueueOptions
{
ConnectionString = _redis.ConnectionString,
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
};
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
Redis = redisOptions
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyEventQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Redis.Streams[0].Stream,
traceId: "trace-123",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().Be(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-123");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var stream = options.Redis.Streams[0].Stream;
var firstEvent = TestData.CreateEvent();
var secondEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
leases.Should().HaveCount(2);
leases.Select(l => l.Message.Event.EventId)
.Should()
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.EventId });
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
leases.Should().ContainSingle();
// Ensure the message has been pending long enough for claim.
await Task.Delay(50);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new RedisNotifyEventQueue(
options,
options.Redis,
NullLogger<RedisNotifyEventQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyEventQueueOptions CreateOptions()
{
var streamOptions = new NotifyRedisEventStreamOptions
{
Stream = "notify:test:events",
ConsumerGroup = "notify-test-consumers",
IdempotencyKeyPrefix = "notify:test:idemp:",
ApproximateMaxLength = 1024
};
var redisOptions = new NotifyRedisEventQueueOptions
{
ConnectionString = _redis.ConnectionString,
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
};
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
Redis = redisOptions
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}

View File

@@ -1,86 +1,86 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.Notify.WebService.Tests;
public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
public NormalizeEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("notify:storage:driver", "memory");
builder.UseSetting("notify:authority:enabled", "false");
builder.UseSetting("notify:authority:developmentSigningKey", "normalize-tests-signing-key-1234567890");
builder.UseSetting("notify:authority:issuer", "test-issuer");
builder.UseSetting("notify:authority:audiences:0", "notify");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
});
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RuleNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-rule@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.rule@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task ChannelNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-channel@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.channel@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task TemplateNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-template@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.template@1", normalized?["schemaVersion"]?.GetValue<string>());
}
private static JsonNode? LoadSampleNode(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return JsonNode.Parse(File.ReadAllText(path));
}
}
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.Notify.WebService.Tests;
public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
public NormalizeEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("notify:storage:driver", "memory");
builder.UseSetting("notify:authority:enabled", "false");
builder.UseSetting("notify:authority:developmentSigningKey", "normalize-tests-signing-key-1234567890");
builder.UseSetting("notify:authority:issuer", "test-issuer");
builder.UseSetting("notify:authority:audiences:0", "notify");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
});
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RuleNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-rule@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.rule@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task ChannelNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-channel@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.channel@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task TemplateNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-template@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.template@1", normalized?["schemaVersion"]?.GetValue<string>());
}
private static JsonNode? LoadSampleNode(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return JsonNode.Parse(File.ReadAllText(path));
}
}

Some files were not shown because too many files have changed in this diff Show More