Restructure solution layout by module
This commit is contained in:
4
src/Notify/StellaOps.Notify.WebService/AGENTS.md
Normal file
4
src/Notify/StellaOps.Notify.WebService/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.WebService — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Notify control plane per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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; }
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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);
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
/// Mongo storage configuration for configuration state and audit logs.
|
||||
/// </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;
|
||||
|
||||
public IList<string> Audiences { get; set; } = new List<string> { "notify" };
|
||||
|
||||
public string ReadScope { get; set; } = "notify.read";
|
||||
|
||||
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
|
||||
{
|
||||
public string Driver { get; set; } = "mongo";
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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, "mongo", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(driver, "memory", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'.");
|
||||
}
|
||||
|
||||
if (string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:connectionString must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storage.Database))
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:database must be provided.");
|
||||
}
|
||||
|
||||
if (storage.CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:commandTimeoutSeconds must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.ReadScope))
|
||||
{
|
||||
throw new InvalidOperationException("notify:authority admin and read 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Notify.WebService;
|
||||
|
||||
public partial class Program;
|
||||
850
src/Notify/StellaOps.Notify.WebService/Program.cs
Normal file
850
src/Notify/StellaOps.Notify.WebService/Program.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notify.WebService.Diagnostics;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notify.WebService.Hosting;
|
||||
using StellaOps.Notify.WebService.Options;
|
||||
using StellaOps.Notify.WebService.Plugins;
|
||||
using StellaOps.Notify.WebService.Security;
|
||||
using StellaOps.Notify.WebService.Services;
|
||||
using StellaOps.Notify.WebService.Internal;
|
||||
using StellaOps.Notify.WebService.Storage.InMemory;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "NOTIFY_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
configurationBuilder.AddNotifyYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/notify.yaml"));
|
||||
};
|
||||
});
|
||||
|
||||
var contentRootPath = builder.Environment.ContentRootPath;
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<NotifyWebServiceOptions>(
|
||||
NotifyWebServiceOptions.SectionName,
|
||||
(opts, _) =>
|
||||
{
|
||||
NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath);
|
||||
NotifyWebServiceOptionsValidator.Validate(opts);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<NotifyWebServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(NotifyWebServiceOptions.SectionName))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
NotifyWebServiceOptionsPostConfigure.Apply(options, contentRootPath);
|
||||
NotifyWebServiceOptionsValidator.Validate(options);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
var minimumLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel);
|
||||
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Is(minimumLevel)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ServiceStatus>();
|
||||
builder.Services.AddSingleton<NotifySchemaMigrationService>();
|
||||
|
||||
if (string.Equals(bootstrapOptions.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Services.AddNotifyMongoStorage(builder.Configuration.GetSection("notify:storage"));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddInMemoryNotifyStorage();
|
||||
}
|
||||
|
||||
var pluginHostOptions = NotifyPluginHostFactory.Build(bootstrapOptions, contentRootPath);
|
||||
builder.Services.AddSingleton(pluginHostOptions);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
|
||||
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
|
||||
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
|
||||
|
||||
ConfigureAuthentication(builder, bootstrapOptions);
|
||||
ConfigureRateLimiting(builder, bootstrapOptions);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var readyStatus = app.Services.GetRequiredService<ServiceStatus>();
|
||||
|
||||
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
|
||||
|
||||
ConfigureRequestPipeline(app, bootstrapOptions);
|
||||
ConfigureEndpoints(app);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options)
|
||||
{
|
||||
if (options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = options.Authority.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = options.Authority.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(options.Authority.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds);
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in options.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(auth =>
|
||||
{
|
||||
auth.AddStellaOpsScopePolicy(NotifyPolicies.Read, options.Authority.ReadScope);
|
||||
auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(jwt =>
|
||||
{
|
||||
jwt.RequireHttpsMetadata = false;
|
||||
jwt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = options.Authority.Issuer,
|
||||
ValidateAudience = options.Authority.Audiences.Count > 0,
|
||||
ValidAudiences = options.Authority.Audiences,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Authority.DevelopmentSigningKey!)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds),
|
||||
NameClaimType = ClaimTypes.Name
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(auth =>
|
||||
{
|
||||
auth.AddPolicy(
|
||||
NotifyPolicies.Read,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(ctx =>
|
||||
HasScope(ctx.User, options.Authority.ReadScope) ||
|
||||
HasScope(ctx.User, options.Authority.AdminScope)));
|
||||
|
||||
auth.AddPolicy(
|
||||
NotifyPolicies.Admin,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(ctx => HasScope(ctx.User, options.Authority.AdminScope)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureRateLimiting(WebApplicationBuilder builder, NotifyWebServiceOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var tenantHeader = options.Api.TenantHeader;
|
||||
var limits = options.Api.RateLimits;
|
||||
|
||||
builder.Services.AddRateLimiter(rateLimiterOptions =>
|
||||
{
|
||||
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
rateLimiterOptions.OnRejected = static (context, _) =>
|
||||
{
|
||||
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.DeliveryHistory, limits.DeliveryHistory, tenantHeader, "deliveries");
|
||||
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, limits.TestSend, tenantHeader, "channel-test");
|
||||
});
|
||||
|
||||
static void ConfigurePolicy(
|
||||
RateLimiterOptions rateLimiterOptions,
|
||||
string policyName,
|
||||
NotifyWebServiceOptions.RateLimitPolicyOptions policy,
|
||||
string tenantHeader,
|
||||
string prefix)
|
||||
{
|
||||
rateLimiterOptions.AddPolicy(policyName, httpContext =>
|
||||
{
|
||||
if (policy is null || !policy.Enabled)
|
||||
{
|
||||
return RateLimitPartition.GetNoLimiter("notify-disabled");
|
||||
}
|
||||
|
||||
var identity = ResolveIdentity(httpContext, tenantHeader, prefix);
|
||||
|
||||
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = policy.TokenLimit,
|
||||
TokensPerPeriod = policy.TokensPerPeriod,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(policy.ReplenishmentPeriodSeconds),
|
||||
QueueLimit = policy.QueueLimit,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static string ResolveIdentity(HttpContext httpContext, string tenantHeader, string prefix)
|
||||
{
|
||||
var tenant = httpContext.Request.Headers.TryGetValue(tenantHeader, out var header) && !StringValues.IsNullOrEmpty(header)
|
||||
? header.ToString().Trim()
|
||||
: "anonymous";
|
||||
|
||||
var subject = httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.Identity?.Name
|
||||
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||
?? "anonymous";
|
||||
|
||||
return string.Concat(prefix, ':', tenant, ':', subject);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task InitialiseAsync(IServiceProvider services, ServiceStatus status, Microsoft.Extensions.Logging.ILogger logger, NotifyWebServiceOptions options)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
if (string.Equals(options.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await RunMongoMigrationsAsync(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
var registry = scope.ServiceProvider.GetRequiredService<INotifyPluginRegistry>();
|
||||
var count = await registry.WarmupAsync();
|
||||
|
||||
stopwatch.Stop();
|
||||
status.RecordReadyCheck(success: true, stopwatch.Elapsed);
|
||||
logger.LogInformation("Notify WebService initialised in {ElapsedMs} ms; loaded {PluginCount} plug-in(s).", stopwatch.Elapsed.TotalMilliseconds, count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
status.RecordReadyCheck(success: false, stopwatch.Elapsed, ex.Message);
|
||||
logger.LogError(ex, "Failed to initialise Notify WebService.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunMongoMigrationsAsync(IServiceProvider services)
|
||||
{
|
||||
var initializerType = Type.GetType("StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo");
|
||||
if (initializerType is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var initializer = services.GetService(initializerType);
|
||||
if (initializer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var method = initializerType.GetMethod("EnsureIndexesAsync", new[] { typeof(CancellationToken) });
|
||||
if (method is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (method.Invoke(initializer, new object[] { CancellationToken.None }) is Task task)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options)
|
||||
{
|
||||
if (options.Telemetry.EnableRequestLogging)
|
||||
{
|
||||
app.UseSerilogRequestLogging(c =>
|
||||
{
|
||||
c.IncludeQueryInRequestPath = true;
|
||||
c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
|
||||
});
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
static void ConfigureEndpoints(WebApplication app)
|
||||
{
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.MapGet("/readyz", (ServiceStatus status) =>
|
||||
{
|
||||
var snapshot = status.CreateSnapshot();
|
||||
if (snapshot.Ready.IsReady)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = "ready",
|
||||
checkedAt = snapshot.Ready.CheckedAt,
|
||||
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds,
|
||||
snapshot.StartedAt
|
||||
});
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
new
|
||||
{
|
||||
status = "unready",
|
||||
snapshot.Ready.Error,
|
||||
checkedAt = snapshot.Ready.CheckedAt,
|
||||
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
|
||||
},
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
});
|
||||
|
||||
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
var tenantHeader = options.Api.TenantHeader;
|
||||
var apiBasePath = options.Api.BasePath.TrimEnd('/');
|
||||
var apiGroup = app.MapGroup(options.Api.BasePath);
|
||||
var internalGroup = app.MapGroup(options.Api.InternalBasePath);
|
||||
|
||||
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
|
||||
.WithName("notify.rules.normalize")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
|
||||
.WithName("notify.channels.normalize");
|
||||
|
||||
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
|
||||
.WithName("notify.templates.normalize");
|
||||
|
||||
apiGroup.MapGet("/rules", async ([FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var rules = await repository.ListAsync(tenant, cancellationToken);
|
||||
return JsonResponse(rules);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, [FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var rule = await repository.GetAsync(tenant, ruleId, cancellationToken);
|
||||
return rule is null ? Results.NotFound() : JsonResponse(rule);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var rule = service.UpgradeRule(body);
|
||||
if (!string.Equals(rule.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(rule, cancellationToken);
|
||||
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", rule.RuleId), rule);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, ruleId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/channels", async (INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var channels = await repository.ListAsync(tenant, cancellationToken);
|
||||
return JsonResponse(channels);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var channel = service.UpgradeChannel(body);
|
||||
if (!string.Equals(channel.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(channel, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channel.ChannelId), channel);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/channels/{channelId}/test", async (string channelId, [FromBody] ChannelTestSendRequest? request, INotifyChannelRepository repository, INotifyChannelTestService testService, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await testService.SendAsync(tenant, channel, request, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(response, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (ChannelTestSendValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
|
||||
|
||||
apiGroup.MapGet("/channels/{channelId}/health", async (string channelId, INotifyChannelRepository repository, INotifyChannelHealthService healthService, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var response = await healthService.CheckAsync(tenant, channel, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(response);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, channelId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/templates", async (INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var templates = await repository.ListAsync(tenant, cancellationToken);
|
||||
return JsonResponse(templates);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var template = service.UpgradeTemplate(body);
|
||||
if (!string.Equals(template.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(template, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", template.TemplateId), template);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, templateId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/deliveries", async (JsonNode? body, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(body.ToJsonString());
|
||||
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpdateAsync(delivery, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId), delivery);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/deliveries", async ([FromServices] INotifyDeliveryRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] string? status, [FromQuery] int? limit, [FromQuery] string? continuationToken, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var effectiveLimit = NormalizeLimit(limit);
|
||||
var result = await repository.QueryAsync(tenant, since, status, effectiveLimit, continuationToken, cancellationToken).ConfigureAwait(false);
|
||||
var payload = new
|
||||
{
|
||||
items = result.Items,
|
||||
continuationToken = result.ContinuationToken,
|
||||
count = result.Items.Count
|
||||
};
|
||||
|
||||
return JsonResponse(payload);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
apiGroup.MapGet("/deliveries/{deliveryId}", async (string deliveryId, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var delivery = await repository.GetAsync(tenant, deliveryId, cancellationToken);
|
||||
return delivery is null ? Results.NotFound() : JsonResponse(delivery);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
apiGroup.MapPost("/digests", async ([FromBody] NotifyDigestDocument payload, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(payload, cancellationToken);
|
||||
return Results.Ok();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var digest = await repository.GetAsync(tenant, actionKey, cancellationToken);
|
||||
return digest is null ? Results.NotFound() : JsonResponse(digest);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapDelete("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.RemoveAsync(tenant, actionKey, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken);
|
||||
return JsonResponse(new { acquired });
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var action = body["action"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Action is required." });
|
||||
}
|
||||
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
TenantId = tenant,
|
||||
Action = action,
|
||||
Actor = user.Identity?.Name ?? "unknown",
|
||||
EntityId = body["entityId"]?.GetValue<string>() ?? string.Empty,
|
||||
EntityType = body["entityType"]?.GetValue<string>() ?? string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Payload = body["payload"] is JsonObject payloadObj
|
||||
? BsonDocument.Parse(payloadObj.ToJsonString())
|
||||
: new BsonDocument()
|
||||
};
|
||||
|
||||
await repository.AppendAsync(entry, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", entry.Id.ToString()), new { entry.Id });
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] int? limit, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var entries = await repository.QueryAsync(tenant, since, limit, cancellationToken);
|
||||
var response = entries.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.TenantId,
|
||||
e.Actor,
|
||||
e.Action,
|
||||
e.EntityId,
|
||||
e.EntityType,
|
||||
e.Timestamp,
|
||||
Payload = JsonNode.Parse(e.Payload.ToJson())
|
||||
});
|
||||
|
||||
return JsonResponse(response);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
}
|
||||
|
||||
static int NormalizeLimit(int? value)
|
||||
{
|
||||
if (value is null || value <= 0)
|
||||
{
|
||||
return 50;
|
||||
}
|
||||
|
||||
return Math.Min(value.Value, 200);
|
||||
}
|
||||
|
||||
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
tenant = string.Empty;
|
||||
error = Results.BadRequest(new { error = $"{tenantHeader} header is required." });
|
||||
return false;
|
||||
}
|
||||
|
||||
tenant = header.ToString().Trim();
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
static string BuildResourceLocation(string basePath, params string[] segments)
|
||||
{
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(basePath);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
builder.Append('/');
|
||||
builder.Append(Uri.EscapeDataString(segment));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
static IResult JsonResponse<T>(T value, int statusCode = StatusCodes.Status200OK, string? location = null)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
return new JsonHttpResult(payload, statusCode, location);
|
||||
}
|
||||
|
||||
static IResult CreatedJson<T>(string location, T value)
|
||||
=> JsonResponse(value, StatusCodes.Status201Created, location);
|
||||
|
||||
static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> upgrade)
|
||||
{
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var model = upgrade(body);
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(model);
|
||||
return Results.Content(json, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
static bool HasScope(ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
if (principal is null || string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll("scope"))
|
||||
{
|
||||
if (string.Equals(claim.Value, scope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static LogEventLevel MapLogLevel(string configuredLevel)
|
||||
{
|
||||
return configuredLevel?.ToLowerInvariant() switch
|
||||
{
|
||||
"verbose" => LogEventLevel.Verbose,
|
||||
"debug" => LogEventLevel.Debug,
|
||||
"warning" => LogEventLevel.Warning,
|
||||
"error" => LogEventLevel.Error,
|
||||
"fatal" => LogEventLevel.Fatal,
|
||||
_ => LogEventLevel.Information
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Notify.WebService.Security;
|
||||
|
||||
internal static class NotifyPolicies
|
||||
{
|
||||
public const string Read = "notify.read";
|
||||
public const string Admin = "notify.admin";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Notify.WebService.Security;
|
||||
|
||||
internal static class NotifyRateLimitPolicies
|
||||
{
|
||||
public const string DeliveryHistory = "notify-deliveries";
|
||||
|
||||
public const string TestSend = "notify-test-send";
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,360 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.InMemory;
|
||||
|
||||
internal static class InMemoryStorageModule
|
||||
{
|
||||
public static IServiceCollection AddInMemoryNotifyStorage(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<InMemoryStore>();
|
||||
services.AddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
|
||||
services.AddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
|
||||
services.AddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
|
||||
services.AddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
|
||||
services.AddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
|
||||
services.AddSingleton<INotifyLockRepository, InMemoryLockRepository>();
|
||||
services.AddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class InMemoryStore
|
||||
{
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> Rules { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> Channels { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyTemplate>> Templates { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDelivery>> Deliveries { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDigestDocument>> Digests { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, LockEntry>> Locks { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentQueue<NotifyAuditEntryDocument>> AuditEntries { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryRuleRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
|
||||
map[rule.RuleId] = rule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map) && map.TryGetValue(ruleId, out var rule))
|
||||
{
|
||||
return Task.FromResult<NotifyRule?>(rule);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyRule?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(map.Values.OrderBy(static r => r.RuleId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(ruleId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryChannelRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
|
||||
map[channel.ChannelId] = channel;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
|
||||
{
|
||||
return Task.FromResult<NotifyChannel?>(channel);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyChannel?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.OrderBy(static c => c.ChannelId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(channelId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryTemplateRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Templates.GetOrAdd(template.TenantId, _ => new ConcurrentDictionary<string, NotifyTemplate>(StringComparer.Ordinal));
|
||||
map[template.TemplateId] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map) && map.TryGetValue(templateId, out var template))
|
||||
{
|
||||
return Task.FromResult<NotifyTemplate?>(template);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyTemplate?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(map.Values.OrderBy(static t => t.TemplateId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(Array.Empty<NotifyTemplate>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(templateId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryDeliveryRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
=> UpdateAsync(delivery, cancellationToken);
|
||||
|
||||
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Deliveries.GetOrAdd(delivery.TenantId, _ => new ConcurrentDictionary<string, NotifyDelivery>(StringComparer.Ordinal));
|
||||
map[delivery.DeliveryId] = delivery;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Deliveries.TryGetValue(tenantId, out var map) && map.TryGetValue(deliveryId, out var delivery))
|
||||
{
|
||||
return Task.FromResult<NotifyDelivery?>(delivery);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDelivery?>(null);
|
||||
}
|
||||
|
||||
public Task<NotifyDeliveryQueryResult> QueryAsync(string tenantId, DateTimeOffset? since, string? status, int? limit, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.Deliveries.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
|
||||
}
|
||||
|
||||
var query = map.Values.AsEnumerable();
|
||||
if (since.HasValue)
|
||||
{
|
||||
query = query.Where(d => d.CreatedAt >= since.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotifyDeliveryStatus>(status, true, out var parsed))
|
||||
{
|
||||
query = query.Where(d => d.Status == parsed);
|
||||
}
|
||||
|
||||
query = query.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.DeliveryId, StringComparer.Ordinal);
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
var items = query.ToList();
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(items, ContinuationToken: null));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryDigestRepository : INotifyDigestRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryDigestRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Digests.GetOrAdd(document.TenantId, _ => new ConcurrentDictionary<string, NotifyDigestDocument>(StringComparer.Ordinal));
|
||||
map[document.ActionKey] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Digests.TryGetValue(tenantId, out var map) && map.TryGetValue(actionKey, out var document))
|
||||
{
|
||||
return Task.FromResult<NotifyDigestDocument?>(document);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDigestDocument?>(null);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Digests.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(actionKey, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryLockRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Locks.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, LockEntry>(StringComparer.Ordinal));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = map.GetOrAdd(resource, _ => new LockEntry(owner, now, now.Add(ttl)));
|
||||
|
||||
lock (entry)
|
||||
{
|
||||
if (entry.Owner == owner || entry.ExpiresAt <= now)
|
||||
{
|
||||
entry.Owner = owner;
|
||||
entry.AcquiredAt = now;
|
||||
entry.ExpiresAt = now.Add(ttl);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Locks.TryGetValue(tenantId, out var map) && map.TryGetValue(resource, out var entry))
|
||||
{
|
||||
lock (entry)
|
||||
{
|
||||
if (entry.Owner == owner)
|
||||
{
|
||||
map.TryRemove(resource, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryAuditRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queue = _store.AuditEntries.GetOrAdd(entry.TenantId, _ => new ConcurrentQueue<NotifyAuditEntryDocument>());
|
||||
queue.Enqueue(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.AuditEntries.TryGetValue(tenantId, out var queue))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
|
||||
}
|
||||
|
||||
var items = queue
|
||||
.Where(entry => !since.HasValue || entry.Timestamp >= since.Value)
|
||||
.OrderByDescending(entry => entry.Timestamp)
|
||||
.ThenBy(entry => entry.Id.ToString(), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (limit.HasValue && limit.Value > 0 && items.Count > limit.Value)
|
||||
{
|
||||
items = items.Take(limit.Value).ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LockEntry
|
||||
{
|
||||
public LockEntry(string owner, DateTimeOffset acquiredAt, DateTimeOffset expiresAt)
|
||||
{
|
||||
Owner = owner;
|
||||
AcquiredAt = acquiredAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public string Owner { get; set; }
|
||||
|
||||
public DateTimeOffset AcquiredAt { get; set; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
2
src/Notify/StellaOps.Notify.WebService/TASKS.md
Normal file
2
src/Notify/StellaOps.Notify.WebService/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify WebService Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — control plane now lives in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
4
src/Notify/StellaOps.Notify.Worker/AGENTS.md
Normal file
4
src/Notify/StellaOps.Notify.Worker/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Worker — Agent Charter
|
||||
|
||||
## Mission
|
||||
Consume events, evaluate rules, and dispatch deliveries per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
52
src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs
Normal file
52
src/Notify/StellaOps.Notify.Worker/NotifyWorkerOptions.cs
Normal file
@@ -0,0 +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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Notify/StellaOps.Notify.Worker/Program.cs
Normal file
33
src/Notify/StellaOps.Notify.Worker/Program.cs
Normal file
@@ -0,0 +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);
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Notify.Worker.Tests")]
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
src/Notify/StellaOps.Notify.Worker/TASKS.md
Normal file
2
src/Notify/StellaOps.Notify.Worker/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Worker Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — worker responsibilities handled in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
43
src/Notify/StellaOps.Notify.Worker/appsettings.json
Normal file
43
src/Notify/StellaOps.Notify.Worker/appsettings.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"worker": {
|
||||
"leaseBatchSize": 16,
|
||||
"leaseDuration": "00:00:30",
|
||||
"idleDelay": "00:00:00.250",
|
||||
"maxConcurrency": 4,
|
||||
"failureBackoffThreshold": 3,
|
||||
"failureBackoffDelay": "00:00:05"
|
||||
},
|
||||
"queue": {
|
||||
"transport": "Redis",
|
||||
"redis": {
|
||||
"connectionString": "localhost:6379",
|
||||
"streams": [
|
||||
{
|
||||
"stream": "notify:events",
|
||||
"consumerGroup": "notify-workers",
|
||||
"idempotencyKeyPrefix": "notify:events:idemp:",
|
||||
"approximateMaxLength": 100000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"deliveryQueue": {
|
||||
"transport": "Redis",
|
||||
"redis": {
|
||||
"connectionString": "localhost:6379",
|
||||
"streamName": "notify:deliveries",
|
||||
"consumerGroup": "notify-delivery",
|
||||
"idempotencyKeyPrefix": "notify:deliveries:idemp:",
|
||||
"deadLetterStreamName": "notify:deliveries:dead"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
422
src/Notify/StellaOps.Notify.sln
Normal file
422
src/Notify/StellaOps.Notify.sln
Normal file
@@ -0,0 +1,422 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService", "StellaOps.Notify.WebService\StellaOps.Notify.WebService.csproj", "{DDE8646D-6EE3-44A1-B433-96943C93FFBB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{43063DE2-1226-4B4C-8047-E44A5632F4EB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F622175F-115B-4DF9-887F-1A517439FA89}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{41F15E67-7190-CF23-3BC4-77E87134CADD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models", "__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj", "{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo", "__Libraries\StellaOps.Notify.Storage.Mongo\StellaOps.Notify.Storage.Mongo.csproj", "{BD147625-3614-49BB-B484-01200F28FF8B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Engine", "__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj", "{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{EFF370F5-788E-4E39-8D80-1DFC6563E45C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{894FBB67-F556-4695-A16D-8B4223D438A4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email", "__Libraries\StellaOps.Notify.Connectors.Email\StellaOps.Notify.Connectors.Email.csproj", "{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Shared", "__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj", "{8048E985-85DE-4B05-AB76-67C436D6516F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack", "__Libraries\StellaOps.Notify.Connectors.Slack\StellaOps.Notify.Connectors.Slack.csproj", "{E94520D5-0D26-4869-AFFD-889D02616D9E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams", "__Libraries\StellaOps.Notify.Connectors.Teams\StellaOps.Notify.Connectors.Teams.csproj", "{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Webhook", "__Libraries\StellaOps.Notify.Connectors.Webhook\StellaOps.Notify.Connectors.Webhook.csproj", "{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue", "__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj", "{F151D567-5A17-4E2F-8D48-348701B1DC23}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker", "StellaOps.Notify.Worker\StellaOps.Notify.Worker.csproj", "{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Email.Tests", "__Tests\StellaOps.Notify.Connectors.Email.Tests\StellaOps.Notify.Connectors.Email.Tests.csproj", "{894EC02C-34C9-43C8-A01B-AF3A85FAE329}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Slack.Tests", "__Tests\StellaOps.Notify.Connectors.Slack.Tests\StellaOps.Notify.Connectors.Slack.Tests.csproj", "{C4F45D77-7646-440D-A153-E52DBF95731D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Connectors.Teams.Tests", "__Tests\StellaOps.Notify.Connectors.Teams.Tests\StellaOps.Notify.Connectors.Teams.Tests.csproj", "{DE4E8371-7933-4D96-9023-36F5D2DDFC56}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Models.Tests", "__Tests\StellaOps.Notify.Models.Tests\StellaOps.Notify.Models.Tests.csproj", "{08428B42-D650-430E-9E51-8A3B18B4C984}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Queue.Tests", "__Tests\StellaOps.Notify.Queue.Tests\StellaOps.Notify.Queue.Tests.csproj", "{84451047-1B04-42D1-9C02-762564CC2B40}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Storage.Mongo.Tests", "__Tests\StellaOps.Notify.Storage.Mongo.Tests\StellaOps.Notify.Storage.Mongo.Tests.csproj", "{C63A47A3-18A6-4251-95A7-392EB58D7B87}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.WebService.Tests", "__Tests\StellaOps.Notify.WebService.Tests\StellaOps.Notify.WebService.Tests.csproj", "{EDAF907C-18A1-4099-9D3B-169B38400420}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Notify.Worker.Tests", "__Tests\StellaOps.Notify.Worker.Tests\StellaOps.Notify.Worker.Tests.csproj", "{66801106-E70A-4D33-8A08-A46C08902603}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DDE8646D-6EE3-44A1-B433-96943C93FFBB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DB941060-49CE-49DA-A9A6-37B0C6FB1BFC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{43063DE2-1226-4B4C-8047-E44A5632F4EB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F622175F-115B-4DF9-887F-1A517439FA89}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C91C6FD-2F33-4C08-B6D1-0C2BF8FB24BC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4EAF4F80-CCE4-4CC3-B8ED-E1D5804A7C98}.Release|x86.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EFF370F5-788E-4E39-8D80-1DFC6563E45C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4C5FB454-3C98-4634-8DE3-D06E1EDDAF05}.Release|x86.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x64.Build.0 = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{894FBB67-F556-4695-A16D-8B4223D438A4}.Release|x86.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x64.Build.0 = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56}.Release|x86.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x64.Build.0 = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984}.Release|x86.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x64.Build.0 = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420}.Release|x86.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x64.Build.0 = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{66801106-E70A-4D33-8A08-A46C08902603}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{59BFF1D2-B0E6-4E17-90ED-7F02669CE4E7} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{BD147625-3614-49BB-B484-01200F28FF8B} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{046AF53B-0C95-4C2B-A608-8F17F4EEAE1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{466C8F11-C43C-455A-AC28-5BF7AEBF04B0} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{8048E985-85DE-4B05-AB76-67C436D6516F} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{E94520D5-0D26-4869-AFFD-889D02616D9E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{2B6CFE1E-137C-4596-8C01-7EE486F9A15E} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{B5AB2C97-AA81-4C02-B62E-DBEE2EEDB43D} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{F151D567-5A17-4E2F-8D48-348701B1DC23} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{7BD19877-3C36-4BD0-8BF7-E1A245106D1C} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||
{894EC02C-34C9-43C8-A01B-AF3A85FAE329} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C4F45D77-7646-440D-A153-E52DBF95731D} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{DE4E8371-7933-4D96-9023-36F5D2DDFC56} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{08428B42-D650-430E-9E51-8A3B18B4C984} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{84451047-1B04-42D1-9C02-762564CC2B40} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{C63A47A3-18A6-4251-95A7-392EB58D7B87} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{EDAF907C-18A1-4099-9D3B-169B38400420} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
{66801106-E70A-4D33-8A08-A46C08902603} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Email — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement SMTP connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Email Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — connector maintained under `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.email",
|
||||
"displayName": "StellaOps Email Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Email.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"email"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "email"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Slack — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver Slack connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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";
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = !string.IsNullOrWhiteSpace(context.Request.Title)
|
||||
? context.Request.Title!
|
||||
: DefaultTitle;
|
||||
var summary = !string.IsNullOrWhiteSpace(context.Request.Summary)
|
||||
? context.Request.Summary!
|
||||
: $"Preview generated for Slack destination at {context.Timestamp:O}.";
|
||||
var bodyText = !string.IsNullOrWhiteSpace(context.Request.Body)
|
||||
? context.Request.Body!
|
||||
: summary;
|
||||
var workspace = context.Channel.Config.Properties.TryGetValue("workspace", out var workspaceName)
|
||||
? workspaceName
|
||||
: null;
|
||||
|
||||
var contextElements = new List<object>
|
||||
{
|
||||
new { type = "mrkdwn", text = $"Preview generated {context.Timestamp:O} · Trace `{context.TraceId}`" }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workspace))
|
||||
{
|
||||
contextElements.Add(new { type = "mrkdwn", text = $"Workspace: `{workspace}`" });
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
channel = context.Target,
|
||||
text = $"{title}\n{bodyText}",
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new { type = "mrkdwn", text = $"*{title}*\n{bodyText}" }
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
elements = contextElements.ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Slack,
|
||||
NotifyDeliveryFormat.Slack,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
context.Request.TextBody ?? bodyText,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = SlackMetadataBuilder.Build(context);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Slack Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — connector scope now in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.slack",
|
||||
"displayName": "StellaOps Slack Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Slack.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"slack"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "slack",
|
||||
"org.stellaops.notify.connector.requiredScopes": "chat:write,chat:write.public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Teams — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Microsoft Teams connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
# Notify Teams Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — connector work now owned by `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
|
||||
> Remark (2025-10-20): Teams test-send now emits Adaptive Card 1.5 payloads with legacy fallback text (`teams.fallbackText` metadata) and hashed webhook secret refs; coverage lives in `StellaOps.Notify.Connectors.Teams.Tests`. `/channels/{id}/health` shares the same metadata builder via `TeamsChannelHealthProvider`, ensuring webhook hashes and sensitive keys stay redacted.
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
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.Teams;
|
||||
|
||||
[ServiceBinding(typeof(INotifyChannelTestProvider), ServiceLifetime.Singleton)]
|
||||
public sealed class TeamsChannelTestProvider : INotifyChannelTestProvider
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private const string DefaultTitle = "Stella Ops Notify Preview";
|
||||
private const int MaxFallbackLength = 512;
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Teams;
|
||||
|
||||
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var title = ResolveTitle(context);
|
||||
var summary = ResolveSummary(context, title);
|
||||
var bodyContent = ResolveBodyContent(context, summary);
|
||||
var fallbackText = BuildFallbackText(context, title, summary, bodyContent);
|
||||
|
||||
var card = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = TeamsMetadataBuilder.CardVersion,
|
||||
body = new object[]
|
||||
{
|
||||
new { type = "TextBlock", weight = "Bolder", text = title, wrap = true },
|
||||
new { type = "TextBlock", text = bodyContent, wrap = true },
|
||||
new { type = "TextBlock", spacing = "None", isSubtle = true, text = $"Trace: {context.TraceId}", wrap = true }
|
||||
}
|
||||
};
|
||||
|
||||
var payload = new
|
||||
{
|
||||
type = "message",
|
||||
summary,
|
||||
text = fallbackText,
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = card
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
|
||||
var preview = NotifyDeliveryRendered.Create(
|
||||
NotifyChannelType.Teams,
|
||||
NotifyDeliveryFormat.Teams,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
fallbackText,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
|
||||
var metadata = TeamsMetadataBuilder.Build(context, fallbackText);
|
||||
|
||||
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
|
||||
}
|
||||
|
||||
private static string ResolveTitle(ChannelTestPreviewContext context)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(context.Request.Title)
|
||||
? context.Request.Title!.Trim()
|
||||
: DefaultTitle;
|
||||
}
|
||||
|
||||
private static string ResolveSummary(ChannelTestPreviewContext context, string title)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(context.Request.Summary))
|
||||
{
|
||||
return context.Request.Summary!.Trim();
|
||||
}
|
||||
|
||||
return $"Preview generated for Teams destination at {context.Timestamp:O}. Title: {title}";
|
||||
}
|
||||
|
||||
private static string ResolveBodyContent(ChannelTestPreviewContext context, string summary)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(context.Request.Body))
|
||||
{
|
||||
return context.Request.Body!.Trim();
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static string BuildFallbackText(ChannelTestPreviewContext context, string title, string summary, string bodyContent)
|
||||
{
|
||||
var fallback = !string.IsNullOrWhiteSpace(context.Request.TextBody)
|
||||
? context.Request.TextBody!.Trim()
|
||||
: summary;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fallback))
|
||||
{
|
||||
fallback = $"{title}: {bodyContent}";
|
||||
}
|
||||
|
||||
fallback = fallback.Trim();
|
||||
fallback = fallback.ReplaceLineEndings(" ");
|
||||
|
||||
if (fallback.Length > MaxFallbackLength)
|
||||
{
|
||||
fallback = fallback[..MaxFallbackLength];
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 _);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.teams",
|
||||
"displayName": "StellaOps Teams Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Teams.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"teams"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "teams",
|
||||
"org.stellaops.notify.connector.cardVersion": "1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Connectors.Webhook — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement generic webhook connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="notify-plugin.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
# Notify Webhook Connector Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — webhook connector maintained in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
@@ -0,0 +1,55 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.notify.connector.webhook",
|
||||
"displayName": "StellaOps Webhook Notify Connector",
|
||||
"version": "0.1.0-alpha",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Notify.Connectors.Webhook.dll"
|
||||
},
|
||||
"capabilities": [
|
||||
"notify-connector",
|
||||
"webhook"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.notify.channel.type": "webhook"
|
||||
}
|
||||
}
|
||||
4
src/Notify/__Libraries/StellaOps.Notify.Engine/AGENTS.md
Normal file
4
src/Notify/__Libraries/StellaOps.Notify.Engine/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Engine — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver rule evaluation, digest, and rendering logic per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
2
src/Notify/__Libraries/StellaOps.Notify.Engine/TASKS.md
Normal file
2
src/Notify/__Libraries/StellaOps.Notify.Engine/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Engine Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — runtime responsibilities moved to `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
4
src/Notify/__Libraries/StellaOps.Notify.Models/AGENTS.md
Normal file
4
src/Notify/__Libraries/StellaOps.Notify.Models/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Models — Agent Charter
|
||||
|
||||
## Mission
|
||||
Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace StellaOps.Notify.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic JSON serializer tuned for Notify canonical documents.
|
||||
/// </summary>
|
||||
public static class NotifyCanonicalJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false, useDeterministicResolver: true);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true, useDeterministicResolver: true);
|
||||
private static readonly JsonSerializerOptions ReadOptions = CreateOptions(writeIndented: false, useDeterministicResolver: false);
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, string[]> PropertyOrderOverrides = new Dictionary<Type, string[]>
|
||||
{
|
||||
{
|
||||
typeof(NotifyRule),
|
||||
new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"ruleId",
|
||||
"tenantId",
|
||||
"name",
|
||||
"description",
|
||||
"enabled",
|
||||
"match",
|
||||
"actions",
|
||||
"labels",
|
||||
"metadata",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyRuleMatch),
|
||||
new[]
|
||||
{
|
||||
"eventKinds",
|
||||
"namespaces",
|
||||
"repositories",
|
||||
"digests",
|
||||
"labels",
|
||||
"componentPurls",
|
||||
"minSeverity",
|
||||
"verdicts",
|
||||
"kevOnly",
|
||||
"vex",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyRuleAction),
|
||||
new[]
|
||||
{
|
||||
"actionId",
|
||||
"channel",
|
||||
"template",
|
||||
"locale",
|
||||
"digest",
|
||||
"throttle",
|
||||
"metadata",
|
||||
"enabled",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyChannel),
|
||||
new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"channelId",
|
||||
"tenantId",
|
||||
"name",
|
||||
"type",
|
||||
"displayName",
|
||||
"description",
|
||||
"config",
|
||||
"enabled",
|
||||
"labels",
|
||||
"metadata",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyChannelConfig),
|
||||
new[]
|
||||
{
|
||||
"secretRef",
|
||||
"target",
|
||||
"endpoint",
|
||||
"properties",
|
||||
"limits",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyTemplate),
|
||||
new[]
|
||||
{
|
||||
"schemaVersion",
|
||||
"templateId",
|
||||
"tenantId",
|
||||
"channelType",
|
||||
"key",
|
||||
"locale",
|
||||
"description",
|
||||
"renderMode",
|
||||
"body",
|
||||
"format",
|
||||
"metadata",
|
||||
"createdBy",
|
||||
"createdAt",
|
||||
"updatedBy",
|
||||
"updatedAt",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyEvent),
|
||||
new[]
|
||||
{
|
||||
"eventId",
|
||||
"kind",
|
||||
"version",
|
||||
"tenant",
|
||||
"ts",
|
||||
"actor",
|
||||
"scope",
|
||||
"payload",
|
||||
"attributes",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyEventScope),
|
||||
new[]
|
||||
{
|
||||
"namespace",
|
||||
"repo",
|
||||
"digest",
|
||||
"component",
|
||||
"image",
|
||||
"labels",
|
||||
"attributes",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyDelivery),
|
||||
new[]
|
||||
{
|
||||
"deliveryId",
|
||||
"tenantId",
|
||||
"ruleId",
|
||||
"actionId",
|
||||
"eventId",
|
||||
"kind",
|
||||
"status",
|
||||
"statusReason",
|
||||
"createdAt",
|
||||
"sentAt",
|
||||
"completedAt",
|
||||
"rendered",
|
||||
"attempts",
|
||||
"metadata",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyDeliveryAttempt),
|
||||
new[]
|
||||
{
|
||||
"timestamp",
|
||||
"status",
|
||||
"statusCode",
|
||||
"reason",
|
||||
}
|
||||
},
|
||||
{
|
||||
typeof(NotifyDeliveryRendered),
|
||||
new[]
|
||||
{
|
||||
"title",
|
||||
"summary",
|
||||
"target",
|
||||
"locale",
|
||||
"channelType",
|
||||
"format",
|
||||
"body",
|
||||
"textBody",
|
||||
"bodyHash",
|
||||
"attachments",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, CompactOptions);
|
||||
|
||||
public static string SerializeIndented<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, PrettyOptions);
|
||||
|
||||
public static T Deserialize<T>(string json)
|
||||
{
|
||||
if (typeof(T) == typeof(NotifyRule))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyRuleDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyRule payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyChannel))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyChannelDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyChannel payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyTemplate))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyTemplateDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyTemplate payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyEvent))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyEventDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyEvent payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(NotifyDelivery))
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<NotifyDeliveryDto>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize NotifyDelivery payload.");
|
||||
return (T)(object)dto.ToModel();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(json, ReadOptions)
|
||||
?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}.");
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions(bool writeIndented, bool useDeterministicResolver)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = writeIndented,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
if (useDeterministicResolver)
|
||||
{
|
||||
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
|
||||
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
|
||||
}
|
||||
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
options.Converters.Add(new Iso8601DurationConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _inner;
|
||||
|
||||
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
}
|
||||
|
||||
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var info = _inner.GetTypeInfo(type, options)
|
||||
?? throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
|
||||
|
||||
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
|
||||
{
|
||||
var ordered = info.Properties
|
||||
.OrderBy(property => GetPropertyOrder(type, property.Name))
|
||||
.ThenBy(property => property.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
info.Properties.Clear();
|
||||
foreach (var property in ordered)
|
||||
{
|
||||
info.Properties.Add(property);
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static int GetPropertyOrder(Type type, string propertyName)
|
||||
{
|
||||
if (PropertyOrderOverrides.TryGetValue(type, out var order) && Array.IndexOf(order, propertyName) is { } index and >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleDto
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
public string? RuleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public NotifyRuleMatchDto? Match { get; set; }
|
||||
public List<NotifyRuleActionDto>? Actions { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public NotifyRule ToModel()
|
||||
=> NotifyRule.Create(
|
||||
RuleId ?? throw new InvalidOperationException("ruleId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
Name ?? throw new InvalidOperationException("name missing"),
|
||||
(Match ?? new NotifyRuleMatchDto()).ToModel(),
|
||||
Actions?.Select(action => action.ToModel()) ?? Array.Empty<NotifyRuleAction>(),
|
||||
Enabled.GetValueOrDefault(true),
|
||||
Description,
|
||||
Labels,
|
||||
Metadata,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedBy,
|
||||
UpdatedAt,
|
||||
SchemaVersion);
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleMatchDto
|
||||
{
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? Namespaces { get; set; }
|
||||
public List<string>? Repositories { get; set; }
|
||||
public List<string>? Digests { get; set; }
|
||||
public List<string>? Labels { get; set; }
|
||||
public List<string>? ComponentPurls { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<string>? Verdicts { get; set; }
|
||||
public bool? KevOnly { get; set; }
|
||||
public NotifyRuleMatchVexDto? Vex { get; set; }
|
||||
|
||||
public NotifyRuleMatch ToModel()
|
||||
=> NotifyRuleMatch.Create(
|
||||
EventKinds,
|
||||
Namespaces,
|
||||
Repositories,
|
||||
Digests,
|
||||
Labels,
|
||||
ComponentPurls,
|
||||
MinSeverity,
|
||||
Verdicts,
|
||||
KevOnly,
|
||||
Vex?.ToModel());
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleMatchVexDto
|
||||
{
|
||||
public bool IncludeAcceptedJustifications { get; set; } = true;
|
||||
public bool IncludeRejectedJustifications { get; set; }
|
||||
public bool IncludeUnknownJustifications { get; set; }
|
||||
public List<string>? JustificationKinds { get; set; }
|
||||
|
||||
public NotifyRuleMatchVex ToModel()
|
||||
=> NotifyRuleMatchVex.Create(
|
||||
IncludeAcceptedJustifications,
|
||||
IncludeRejectedJustifications,
|
||||
IncludeUnknownJustifications,
|
||||
JustificationKinds);
|
||||
}
|
||||
|
||||
internal sealed class NotifyRuleActionDto
|
||||
{
|
||||
public string? ActionId { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public TimeSpan? Throttle { get; set; }
|
||||
public string? Locale { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public NotifyRuleAction ToModel()
|
||||
=> NotifyRuleAction.Create(
|
||||
ActionId ?? throw new InvalidOperationException("actionId missing"),
|
||||
Channel ?? throw new InvalidOperationException("channel missing"),
|
||||
Template,
|
||||
Digest,
|
||||
Throttle,
|
||||
Locale,
|
||||
Enabled.GetValueOrDefault(true),
|
||||
Metadata);
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelDto
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
public string? ChannelId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public NotifyChannelType Type { get; set; }
|
||||
public NotifyChannelConfigDto? Config { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public NotifyChannel ToModel()
|
||||
=> NotifyChannel.Create(
|
||||
ChannelId ?? throw new InvalidOperationException("channelId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
Name ?? throw new InvalidOperationException("name missing"),
|
||||
Type,
|
||||
(Config ?? new NotifyChannelConfigDto()).ToModel(),
|
||||
DisplayName,
|
||||
Description,
|
||||
Enabled.GetValueOrDefault(true),
|
||||
Labels,
|
||||
Metadata,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedBy,
|
||||
UpdatedAt,
|
||||
SchemaVersion);
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelConfigDto
|
||||
{
|
||||
public string? SecretRef { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? Endpoint { get; set; }
|
||||
public Dictionary<string, string>? Properties { get; set; }
|
||||
public NotifyChannelLimitsDto? Limits { get; set; }
|
||||
|
||||
public NotifyChannelConfig ToModel()
|
||||
=> NotifyChannelConfig.Create(
|
||||
SecretRef ?? throw new InvalidOperationException("secretRef missing"),
|
||||
Target,
|
||||
Endpoint,
|
||||
Properties,
|
||||
Limits?.ToModel());
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelLimitsDto
|
||||
{
|
||||
public int? Concurrency { get; set; }
|
||||
public int? RequestsPerMinute { get; set; }
|
||||
public TimeSpan? Timeout { get; set; }
|
||||
public int? MaxBatchSize { get; set; }
|
||||
|
||||
public NotifyChannelLimits ToModel()
|
||||
=> new(
|
||||
Concurrency,
|
||||
RequestsPerMinute,
|
||||
Timeout,
|
||||
MaxBatchSize);
|
||||
}
|
||||
|
||||
internal sealed class NotifyTemplateDto
|
||||
{
|
||||
public string? SchemaVersion { get; set; }
|
||||
public string? TemplateId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public NotifyChannelType ChannelType { get; set; }
|
||||
public string? Key { get; set; }
|
||||
public string? Locale { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public NotifyTemplateRenderMode RenderMode { get; set; } = NotifyTemplateRenderMode.Markdown;
|
||||
public NotifyDeliveryFormat Format { get; set; } = NotifyDeliveryFormat.Json;
|
||||
public string? Description { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
|
||||
public NotifyTemplate ToModel()
|
||||
=> NotifyTemplate.Create(
|
||||
TemplateId ?? throw new InvalidOperationException("templateId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
ChannelType,
|
||||
Key ?? throw new InvalidOperationException("key missing"),
|
||||
Locale ?? throw new InvalidOperationException("locale missing"),
|
||||
Body ?? throw new InvalidOperationException("body missing"),
|
||||
RenderMode,
|
||||
Format,
|
||||
Description,
|
||||
Metadata,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedBy,
|
||||
UpdatedAt,
|
||||
SchemaVersion);
|
||||
}
|
||||
|
||||
internal sealed class NotifyEventDto
|
||||
{
|
||||
public Guid EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? Tenant { get; set; }
|
||||
public DateTimeOffset Ts { get; set; }
|
||||
public JsonNode? Payload { get; set; }
|
||||
public NotifyEventScopeDto? Scope { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
|
||||
public NotifyEvent ToModel()
|
||||
=> NotifyEvent.Create(
|
||||
EventId,
|
||||
Kind ?? throw new InvalidOperationException("kind missing"),
|
||||
Tenant ?? throw new InvalidOperationException("tenant missing"),
|
||||
Ts,
|
||||
Payload,
|
||||
Scope?.ToModel(),
|
||||
Version,
|
||||
Actor,
|
||||
Attributes);
|
||||
}
|
||||
|
||||
internal sealed class NotifyEventScopeDto
|
||||
{
|
||||
public string? Namespace { get; set; }
|
||||
public string? Repo { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public string? Component { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
|
||||
public NotifyEventScope ToModel()
|
||||
=> NotifyEventScope.Create(
|
||||
Namespace,
|
||||
Repo,
|
||||
Digest,
|
||||
Component,
|
||||
Image,
|
||||
Labels,
|
||||
Attributes);
|
||||
}
|
||||
|
||||
internal sealed class NotifyDeliveryDto
|
||||
{
|
||||
public string? DeliveryId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? RuleId { get; set; }
|
||||
public string? ActionId { get; set; }
|
||||
public Guid EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public NotifyDeliveryStatus Status { get; set; }
|
||||
public string? StatusReason { get; set; }
|
||||
public NotifyDeliveryRenderedDto? Rendered { get; set; }
|
||||
public List<NotifyDeliveryAttemptDto>? Attempts { get; set; }
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
public DateTimeOffset? CreatedAt { get; set; }
|
||||
public DateTimeOffset? SentAt { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
|
||||
public NotifyDelivery ToModel()
|
||||
=> NotifyDelivery.Create(
|
||||
DeliveryId ?? throw new InvalidOperationException("deliveryId missing"),
|
||||
TenantId ?? throw new InvalidOperationException("tenantId missing"),
|
||||
RuleId ?? throw new InvalidOperationException("ruleId missing"),
|
||||
ActionId ?? throw new InvalidOperationException("actionId missing"),
|
||||
EventId,
|
||||
Kind ?? throw new InvalidOperationException("kind missing"),
|
||||
Status,
|
||||
StatusReason,
|
||||
Rendered?.ToModel(),
|
||||
Attempts?.Select(attempt => attempt.ToModel()),
|
||||
Metadata,
|
||||
CreatedAt,
|
||||
SentAt,
|
||||
CompletedAt);
|
||||
}
|
||||
|
||||
internal sealed class NotifyDeliveryAttemptDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public NotifyDeliveryAttemptStatus Status { get; set; }
|
||||
public int? StatusCode { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public NotifyDeliveryAttempt ToModel()
|
||||
=> new(Timestamp, Status, StatusCode, Reason);
|
||||
}
|
||||
|
||||
internal sealed class NotifyDeliveryRenderedDto
|
||||
{
|
||||
public NotifyChannelType ChannelType { get; set; }
|
||||
public NotifyDeliveryFormat Format { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? TextBody { get; set; }
|
||||
public string? Locale { get; set; }
|
||||
public string? BodyHash { get; set; }
|
||||
public List<string>? Attachments { get; set; }
|
||||
|
||||
public NotifyDeliveryRendered ToModel()
|
||||
=> NotifyDeliveryRendered.Create(
|
||||
ChannelType,
|
||||
Format,
|
||||
Target ?? throw new InvalidOperationException("target missing"),
|
||||
Title ?? throw new InvalidOperationException("title missing"),
|
||||
Body ?? throw new InvalidOperationException("body missing"),
|
||||
Summary,
|
||||
TextBody,
|
||||
Locale,
|
||||
BodyHash,
|
||||
Attachments);
|
||||
}
|
||||
235
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyChannel.cs
Normal file
235
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyChannel.cs
Normal file
@@ -0,0 +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; }
|
||||
}
|
||||
252
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyDelivery.cs
Normal file
252
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyDelivery.cs
Normal file
@@ -0,0 +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; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery lifecycle states tracked for audit and retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryStatus
|
||||
{
|
||||
Pending,
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
Digested,
|
||||
Dropped,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual attempt status recorded during delivery retries.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum NotifyDeliveryAttemptStatus
|
||||
{
|
||||
Enqueued,
|
||||
Sending,
|
||||
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
|
||||
{
|
||||
Slack,
|
||||
Teams,
|
||||
Email,
|
||||
Webhook,
|
||||
Json,
|
||||
}
|
||||
168
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyEvent.cs
Normal file
168
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyEvent.cs
Normal file
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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 FeedserExportCompleted = "feedser.export.completed";
|
||||
public const string VexerExportCompleted = "vexer.export.completed";
|
||||
}
|
||||
388
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyRule.cs
Normal file
388
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyRule.cs
Normal file
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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());
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
}
|
||||
130
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyTemplate.cs
Normal file
130
src/Notify/__Libraries/StellaOps.Notify.Models/NotifyTemplate.cs
Normal file
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
2
src/Notify/__Libraries/StellaOps.Notify.Models/TASKS.md
Normal file
2
src/Notify/__Libraries/StellaOps.Notify.Models/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify Models Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — scope moved to `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
4
src/Notify/__Libraries/StellaOps.Notify.Queue/AGENTS.md
Normal file
4
src/Notify/__Libraries/StellaOps.Notify.Queue/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.Queue — Agent Charter
|
||||
|
||||
## Mission
|
||||
Provide event & delivery queues for Notify per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisposable
|
||||
{
|
||||
private const string TransportName = "nats";
|
||||
|
||||
private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default;
|
||||
|
||||
private readonly NotifyDeliveryQueueOptions _queueOptions;
|
||||
private readonly NotifyNatsDeliveryQueueOptions _options;
|
||||
private readonly ILogger<NatsNotifyDeliveryQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory;
|
||||
|
||||
private NatsConnection? _connection;
|
||||
private NatsJSContext? _jsContext;
|
||||
private INatsJSConsumer? _consumer;
|
||||
private bool _disposed;
|
||||
|
||||
public NatsNotifyDeliveryQueue(
|
||||
NotifyDeliveryQueueOptions queueOptions,
|
||||
NotifyNatsDeliveryQueueOptions options,
|
||||
ILogger<NatsNotifyDeliveryQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
{
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? ((opts, token) => new ValueTask<NatsConnection>(new NatsConnection(opts)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Url))
|
||||
{
|
||||
throw new InvalidOperationException("NATS connection URL must be configured for the Notify delivery queue.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Stream) || string.IsNullOrWhiteSpace(_options.Subject))
|
||||
{
|
||||
throw new InvalidOperationException("NATS stream and subject must be configured for the Notify delivery queue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
|
||||
NotifyDeliveryQueueMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(message.Delivery));
|
||||
var headers = BuildHeaders(message);
|
||||
|
||||
var publishOpts = new NatsJSPubOpts
|
||||
{
|
||||
MsgId = message.IdempotencyKey,
|
||||
RetryAttempts = 0
|
||||
};
|
||||
|
||||
var ack = await js.PublishAsync(
|
||||
_options.Subject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
publishOpts,
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ack.Duplicate)
|
||||
{
|
||||
NotifyQueueMetrics.RecordDeduplicated(TransportName, _options.Stream);
|
||||
_logger.LogDebug(
|
||||
"Duplicate Notify delivery enqueue detected for delivery {DeliveryId}.",
|
||||
message.Delivery.DeliveryId);
|
||||
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), true);
|
||||
}
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, _options.Stream);
|
||||
_logger.LogDebug(
|
||||
"Enqueued Notify delivery {DeliveryId} into NATS stream {Stream} (sequence {Sequence}).",
|
||||
message.Delivery.DeliveryId,
|
||||
ack.Stream,
|
||||
ack.Seq);
|
||||
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> LeaseAsync(
|
||||
NotifyQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = request.BatchSize,
|
||||
Expires = request.LeaseDuration,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(request.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> ClaimExpiredAsync(
|
||||
NotifyQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = options.BatchSize,
|
||||
Expires = options.MinIdleTime,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(options.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1);
|
||||
if (deliveries <= 1)
|
||||
{
|
||||
await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal async Task AcknowledgeAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Acknowledged Notify delivery {DeliveryId} (sequence {Sequence}).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.MessageId);
|
||||
}
|
||||
|
||||
internal async Task RenewLeaseAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await lease.RawMessage.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
|
||||
lease.RefreshLease(expires);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewed NATS lease for Notify delivery {DeliveryId} until {Expires:u}.",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
expires);
|
||||
}
|
||||
|
||||
internal async Task ReleaseAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry
|
||||
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Notify delivery {DeliveryId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt);
|
||||
|
||||
await DeadLetterAsync(
|
||||
lease,
|
||||
$"max-delivery-attempts:{lease.Attempt}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry)
|
||||
{
|
||||
var delay = CalculateBackoff(lease.Attempt);
|
||||
await lease.RawMessage.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordRetry(TransportName, _options.Stream);
|
||||
_logger.LogInformation(
|
||||
"Scheduled Notify delivery {DeliveryId} for retry with delay {Delay} (attempt {Attempt}).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
delay,
|
||||
lease.Attempt);
|
||||
}
|
||||
else
|
||||
{
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
_logger.LogInformation(
|
||||
"Abandoned Notify delivery {DeliveryId} after {Attempt} attempt(s).",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task DeadLetterAsync(
|
||||
NatsNotifyDeliveryLease lease,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(lease.Message.Delivery));
|
||||
var headers = BuildDeadLetterHeaders(lease, reason);
|
||||
|
||||
await js.PublishAsync(
|
||||
_options.DeadLetterSubject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
new NatsJSPubOpts(),
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordDeadLetter(TransportName, _options.DeadLetterStream);
|
||||
_logger.LogError(
|
||||
"Dead-lettered Notify delivery {DeliveryId} (attempt {Attempt}): {Reason}",
|
||||
lease.Message.Delivery.DeliveryId,
|
||||
lease.Attempt,
|
||||
reason);
|
||||
}
|
||||
|
||||
internal async Task PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_jsContext is not null)
|
||||
{
|
||||
return _jsContext;
|
||||
}
|
||||
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_jsContext ??= new NatsJSContext(connection);
|
||||
return _jsContext;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync(
|
||||
NatsJSContext js,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
DurableName = _options.DurableConsumer,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
|
||||
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
|
||||
AckWait = ToNanoseconds(_options.AckWait),
|
||||
MaxAckPending = _options.MaxAckPending,
|
||||
MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts),
|
||||
FilterSubjects = new[] { _options.Subject }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.CreateConsumerAsync(
|
||||
_options.Stream,
|
||||
consumerConfig,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException apiEx)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
|
||||
apiEx.Error?.Code,
|
||||
_options.DurableConsumer);
|
||||
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
_options.Stream,
|
||||
_options.DurableConsumer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _consumer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = _options.Url!,
|
||||
Name = "stellaops-notify-delivery",
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
RequestTimeout = TimeSpan.FromSeconds(20),
|
||||
PingInterval = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false);
|
||||
await _connection.ConnectAsync().ConfigureAwait(false);
|
||||
return _connection;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify delivery stream {Stream} ({Subject}).", _options.Stream, _options.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify delivery dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject);
|
||||
}
|
||||
}
|
||||
|
||||
private NatsNotifyDeliveryLease? CreateLease(
|
||||
NatsJSMsg<byte[]> message,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration)
|
||||
{
|
||||
var payloadBytes = message.Data ?? Array.Empty<byte>();
|
||||
if (payloadBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NotifyDelivery delivery;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(payloadBytes);
|
||||
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to deserialize Notify delivery payload for NATS message {Sequence}.",
|
||||
message.Metadata?.Sequence.Stream);
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = message.Headers ?? new NatsHeaders();
|
||||
|
||||
var deliveryId = TryGetHeader(headers, NotifyQueueFields.DeliveryId) ?? delivery.DeliveryId;
|
||||
var channelId = TryGetHeader(headers, NotifyQueueFields.ChannelId);
|
||||
var channelTypeRaw = TryGetHeader(headers, NotifyQueueFields.ChannelType);
|
||||
if (channelId is null || channelTypeRaw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(channelTypeRaw, ignoreCase: true, out var channelType))
|
||||
{
|
||||
_logger.LogWarning("Unknown channel type '{ChannelType}' for delivery {DeliveryId}.", channelTypeRaw, deliveryId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var traceId = TryGetHeader(headers, NotifyQueueFields.TraceId);
|
||||
var partitionKey = TryGetHeader(headers, NotifyQueueFields.PartitionKey) ?? channelId;
|
||||
var idempotencyKey = TryGetHeader(headers, NotifyQueueFields.IdempotencyKey) ?? delivery.DeliveryId;
|
||||
|
||||
var enqueuedAt = TryGetHeader(headers, NotifyQueueFields.EnqueuedAt) is { } enqueuedRaw
|
||||
&& long.TryParse(enqueuedRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(unix)
|
||||
: now;
|
||||
|
||||
var attempt = TryGetHeader(headers, NotifyQueueFields.Attempt) is { } attemptRaw
|
||||
&& int.TryParse(attemptRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempt)
|
||||
? parsedAttempt
|
||||
: 1;
|
||||
|
||||
if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0)
|
||||
{
|
||||
var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered;
|
||||
if (deliveredInt > attempt)
|
||||
{
|
||||
attempt = deliveredInt;
|
||||
}
|
||||
}
|
||||
|
||||
var attributes = ExtractAttributes(headers);
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n");
|
||||
|
||||
var queueMessage = new NotifyDeliveryQueueMessage(
|
||||
delivery,
|
||||
channelId,
|
||||
channelType,
|
||||
_options.Subject,
|
||||
traceId,
|
||||
attributes);
|
||||
|
||||
return new NatsNotifyDeliveryLease(
|
||||
this,
|
||||
message,
|
||||
messageId,
|
||||
queueMessage,
|
||||
attempt,
|
||||
consumer,
|
||||
enqueuedAt,
|
||||
leaseExpires,
|
||||
idempotencyKey);
|
||||
}
|
||||
|
||||
private NatsHeaders BuildHeaders(NotifyDeliveryQueueMessage message)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.DeliveryId, message.Delivery.DeliveryId },
|
||||
{ NotifyQueueFields.ChannelId, message.ChannelId },
|
||||
{ NotifyQueueFields.ChannelType, message.ChannelType.ToString() },
|
||||
{ NotifyQueueFields.Tenant, message.Delivery.TenantId },
|
||||
{ NotifyQueueFields.Attempt, "1" },
|
||||
{ NotifyQueueFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, message.IdempotencyKey },
|
||||
{ NotifyQueueFields.PartitionKey, message.PartitionKey }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, message.TraceId!);
|
||||
}
|
||||
|
||||
foreach (var kvp in message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private NatsHeaders BuildDeadLetterHeaders(NatsNotifyDeliveryLease lease, string reason)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.DeliveryId, lease.Message.Delivery.DeliveryId },
|
||||
{ NotifyQueueFields.ChannelId, lease.Message.ChannelId },
|
||||
{ NotifyQueueFields.ChannelType, lease.Message.ChannelType.ToString() },
|
||||
{ NotifyQueueFields.Tenant, lease.Message.Delivery.TenantId },
|
||||
{ NotifyQueueFields.Attempt, lease.Attempt.ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey },
|
||||
{ "deadletter-reason", reason }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.Message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, lease.Message.TraceId!);
|
||||
}
|
||||
|
||||
foreach (var kvp in lease.Message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static string? TryGetHeader(NatsHeaders headers, string key)
|
||||
{
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
var value = values[0];
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers)
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in headers.Keys)
|
||||
{
|
||||
if (!key.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
attributes[key[NotifyQueueFields.AttributePrefix.Length..]] = values[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
return attributes.Count == 0
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(attributes);
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryInitialBackoff
|
||||
: _options.RetryDelay;
|
||||
|
||||
if (initial <= TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (attempt <= 1)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryMaxBackoff
|
||||
: initial;
|
||||
|
||||
var exponent = attempt - 1;
|
||||
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
|
||||
return TimeSpan.FromTicks(resultTicks);
|
||||
}
|
||||
|
||||
private static long ToNanoseconds(TimeSpan value)
|
||||
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
|
||||
|
||||
private 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.Queue.Nats;
|
||||
|
||||
internal sealed class NatsNotifyEventQueue : INotifyEventQueue, IAsyncDisposable
|
||||
{
|
||||
private const string TransportName = "nats";
|
||||
|
||||
private static readonly INatsSerializer<byte[]> PayloadSerializer = NatsRawSerializer<byte[]>.Default;
|
||||
|
||||
private readonly NotifyEventQueueOptions _queueOptions;
|
||||
private readonly NotifyNatsEventQueueOptions _options;
|
||||
private readonly ILogger<NatsNotifyEventQueue> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _connectionGate = new(1, 1);
|
||||
private readonly Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>> _connectionFactory;
|
||||
|
||||
private NatsConnection? _connection;
|
||||
private NatsJSContext? _jsContext;
|
||||
private INatsJSConsumer? _consumer;
|
||||
private bool _disposed;
|
||||
|
||||
public NatsNotifyEventQueue(
|
||||
NotifyEventQueueOptions queueOptions,
|
||||
NotifyNatsEventQueueOptions options,
|
||||
ILogger<NatsNotifyEventQueue> logger,
|
||||
TimeProvider timeProvider,
|
||||
Func<NatsOpts, CancellationToken, ValueTask<NatsConnection>>? connectionFactory = null)
|
||||
{
|
||||
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? ((opts, cancellationToken) => new ValueTask<NatsConnection>(new NatsConnection(opts)));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Url))
|
||||
{
|
||||
throw new InvalidOperationException("NATS connection URL must be configured for the Notify event queue.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.Stream) || string.IsNullOrWhiteSpace(_options.Subject))
|
||||
{
|
||||
throw new InvalidOperationException("NATS stream and subject must be configured for the Notify event queue.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
|
||||
NotifyQueueEventMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var idempotencyKey = string.IsNullOrWhiteSpace(message.IdempotencyKey)
|
||||
? message.Event.EventId.ToString("N")
|
||||
: message.IdempotencyKey;
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(message.Event));
|
||||
var headers = BuildHeaders(message, idempotencyKey);
|
||||
|
||||
var publishOpts = new NatsJSPubOpts
|
||||
{
|
||||
MsgId = idempotencyKey,
|
||||
RetryAttempts = 0
|
||||
};
|
||||
|
||||
var ack = await js.PublishAsync(
|
||||
_options.Subject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
publishOpts,
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ack.Duplicate)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Duplicate Notify event enqueue detected for idempotency token {Token}.",
|
||||
idempotencyKey);
|
||||
|
||||
NotifyQueueMetrics.RecordDeduplicated(TransportName, _options.Stream);
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), true);
|
||||
}
|
||||
|
||||
NotifyQueueMetrics.RecordEnqueued(TransportName, _options.Stream);
|
||||
_logger.LogDebug(
|
||||
"Enqueued Notify event {EventId} into NATS stream {Stream} (sequence {Sequence}).",
|
||||
message.Event.EventId,
|
||||
ack.Stream,
|
||||
ack.Seq);
|
||||
|
||||
return new NotifyQueueEnqueueResult(ack.Seq.ToString(), false);
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(
|
||||
NotifyQueueLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = request.BatchSize,
|
||||
Expires = request.LeaseDuration,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(request.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var lease = CreateLease(msg, request.Consumer, now, request.LeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(
|
||||
NotifyQueueClaimOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var consumer = await EnsureStreamAndConsumerAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var fetchOpts = new NatsJSFetchOpts
|
||||
{
|
||||
MaxMsgs = options.BatchSize,
|
||||
Expires = options.MinIdleTime,
|
||||
IdleHeartbeat = _options.IdleHeartbeat
|
||||
};
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var leases = new List<INotifyQueueLease<NotifyQueueEventMessage>>(options.BatchSize);
|
||||
|
||||
await foreach (var msg in consumer.FetchAsync(PayloadSerializer, fetchOpts, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var deliveries = (int)(msg.Metadata?.NumDelivered ?? 1);
|
||||
if (deliveries <= 1)
|
||||
{
|
||||
await msg.NakAsync(new AckOpts(), TimeSpan.Zero, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lease = CreateLease(msg, options.ClaimantConsumer, now, _queueOptions.DefaultLeaseDuration);
|
||||
if (lease is null)
|
||||
{
|
||||
await msg.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
leases.Add(lease);
|
||||
}
|
||||
|
||||
return leases;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_connectionGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
internal async Task AcknowledgeAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Acknowledged Notify event {EventId} (sequence {Sequence}).",
|
||||
lease.Message.Event.EventId,
|
||||
lease.MessageId);
|
||||
}
|
||||
|
||||
internal async Task RenewLeaseAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await lease.RawMessage.AckProgressAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
|
||||
lease.RefreshLease(expires);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Renewed NATS lease for Notify event {EventId} until {Expires:u}.",
|
||||
lease.Message.Event.EventId,
|
||||
expires);
|
||||
}
|
||||
|
||||
internal async Task ReleaseAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
NotifyQueueReleaseDisposition disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry
|
||||
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Notify event {EventId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Attempt);
|
||||
|
||||
await DeadLetterAsync(
|
||||
lease,
|
||||
$"max-delivery-attempts:{lease.Attempt}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposition == NotifyQueueReleaseDisposition.Retry)
|
||||
{
|
||||
var delay = CalculateBackoff(lease.Attempt);
|
||||
await lease.RawMessage.NakAsync(new AckOpts(), delay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordRetry(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scheduled Notify event {EventId} for retry with delay {Delay} (attempt {Attempt}).",
|
||||
lease.Message.Event.EventId,
|
||||
delay,
|
||||
lease.Attempt);
|
||||
}
|
||||
else
|
||||
{
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
NotifyQueueMetrics.RecordAck(TransportName, _options.Stream);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Abandoned Notify event {EventId} after {Attempt} attempt(s).",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Attempt);
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task DeadLetterAsync(
|
||||
NatsNotifyEventLease lease,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!lease.TryBeginCompletion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await lease.RawMessage.AckTerminateAsync(new AckOpts(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var js = await GetJetStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var headers = BuildDeadLetterHeaders(lease, reason);
|
||||
var payload = Encoding.UTF8.GetBytes(NotifyCanonicalJsonSerializer.Serialize(lease.Message.Event));
|
||||
|
||||
await js.PublishAsync(
|
||||
_options.DeadLetterSubject,
|
||||
payload,
|
||||
PayloadSerializer,
|
||||
new NatsJSPubOpts(),
|
||||
headers,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
NotifyQueueMetrics.RecordDeadLetter(TransportName, _options.DeadLetterStream);
|
||||
|
||||
_logger.LogError(
|
||||
"Dead-lettered Notify event {EventId} (attempt {Attempt}): {Reason}",
|
||||
lease.Message.Event.EventId,
|
||||
lease.Attempt,
|
||||
reason);
|
||||
}
|
||||
|
||||
internal async Task PingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await connection.PingAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<NatsJSContext> GetJetStreamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_jsContext is not null)
|
||||
{
|
||||
return _jsContext;
|
||||
}
|
||||
|
||||
var connection = await EnsureConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_jsContext ??= new NatsJSContext(connection);
|
||||
return _jsContext;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<INatsJSConsumer> EnsureStreamAndConsumerAsync(
|
||||
NatsJSContext js,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_consumer is not null)
|
||||
{
|
||||
return _consumer;
|
||||
}
|
||||
|
||||
await EnsureStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
await EnsureDeadLetterStreamAsync(js, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var consumerConfig = new ConsumerConfig
|
||||
{
|
||||
DurableName = _options.DurableConsumer,
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
|
||||
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
|
||||
AckWait = ToNanoseconds(_options.AckWait),
|
||||
MaxAckPending = _options.MaxAckPending,
|
||||
MaxDeliver = Math.Max(1, _queueOptions.MaxDeliveryAttempts),
|
||||
FilterSubjects = new[] { _options.Subject }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_consumer = await js.CreateConsumerAsync(
|
||||
_options.Stream,
|
||||
consumerConfig,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException apiEx)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
apiEx,
|
||||
"CreateConsumerAsync failed with code {Code}; attempting to fetch existing durable consumer {Durable}.",
|
||||
apiEx.Error?.Code,
|
||||
_options.DurableConsumer);
|
||||
|
||||
_consumer = await js.GetConsumerAsync(
|
||||
_options.Stream,
|
||||
_options.DurableConsumer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _consumer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NatsConnection> EnsureConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
var opts = new NatsOpts
|
||||
{
|
||||
Url = _options.Url!,
|
||||
Name = "stellaops-notify-queue",
|
||||
CommandTimeout = TimeSpan.FromSeconds(10),
|
||||
RequestTimeout = TimeSpan.FromSeconds(20),
|
||||
PingInterval = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
_connection = await _connectionFactory(opts, cancellationToken).ConfigureAwait(false);
|
||||
await _connection.ConnectAsync().ConfigureAwait(false);
|
||||
return _connection;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.Stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.Stream, subjects: new[] { _options.Subject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify stream {Stream} ({Subject}).", _options.Stream, _options.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDeadLetterStreamAsync(NatsJSContext js, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.GetStreamAsync(_options.DeadLetterStream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (NatsJSApiException ex) when (ex.Error?.Code == 404)
|
||||
{
|
||||
var config = new StreamConfig(name: _options.DeadLetterStream, subjects: new[] { _options.DeadLetterSubject })
|
||||
{
|
||||
Retention = StreamConfigRetention.Workqueue,
|
||||
Storage = StreamConfigStorage.File,
|
||||
MaxConsumers = -1,
|
||||
MaxMsgs = -1,
|
||||
MaxBytes = -1
|
||||
};
|
||||
|
||||
await js.CreateStreamAsync(config, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Created NATS Notify dead-letter stream {Stream} ({Subject}).", _options.DeadLetterStream, _options.DeadLetterSubject);
|
||||
}
|
||||
}
|
||||
|
||||
private NatsNotifyEventLease? CreateLease(
|
||||
NatsJSMsg<byte[]> message,
|
||||
string consumer,
|
||||
DateTimeOffset now,
|
||||
TimeSpan leaseDuration)
|
||||
{
|
||||
var payloadBytes = message.Data ?? Array.Empty<byte>();
|
||||
if (payloadBytes.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NotifyEvent notifyEvent;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(payloadBytes);
|
||||
notifyEvent = NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to deserialize Notify event payload for NATS message {Sequence}.",
|
||||
message.Metadata?.Sequence.Stream);
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = message.Headers ?? new NatsHeaders();
|
||||
|
||||
var idempotencyKey = TryGetHeader(headers, NotifyQueueFields.IdempotencyKey)
|
||||
?? notifyEvent.EventId.ToString("N");
|
||||
|
||||
var partitionKey = TryGetHeader(headers, NotifyQueueFields.PartitionKey);
|
||||
var traceId = TryGetHeader(headers, NotifyQueueFields.TraceId);
|
||||
var enqueuedAt = TryGetHeader(headers, NotifyQueueFields.EnqueuedAt) is { } enqueuedRaw
|
||||
&& long.TryParse(enqueuedRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(unix)
|
||||
: now;
|
||||
|
||||
var attempt = TryGetHeader(headers, NotifyQueueFields.Attempt) is { } attemptRaw
|
||||
&& int.TryParse(attemptRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempt)
|
||||
? parsedAttempt
|
||||
: 1;
|
||||
|
||||
if (message.Metadata?.NumDelivered is ulong delivered && delivered > 0)
|
||||
{
|
||||
var deliveredInt = delivered > int.MaxValue ? int.MaxValue : (int)delivered;
|
||||
if (deliveredInt > attempt)
|
||||
{
|
||||
attempt = deliveredInt;
|
||||
}
|
||||
}
|
||||
|
||||
var attributes = ExtractAttributes(headers);
|
||||
var leaseExpires = now.Add(leaseDuration);
|
||||
var messageId = message.Metadata?.Sequence.Stream.ToString() ?? Guid.NewGuid().ToString("n");
|
||||
|
||||
var queueMessage = new NotifyQueueEventMessage(
|
||||
notifyEvent,
|
||||
_options.Subject,
|
||||
idempotencyKey,
|
||||
partitionKey,
|
||||
traceId,
|
||||
attributes);
|
||||
|
||||
return new NatsNotifyEventLease(
|
||||
this,
|
||||
message,
|
||||
messageId,
|
||||
queueMessage,
|
||||
attempt,
|
||||
consumer,
|
||||
enqueuedAt,
|
||||
leaseExpires);
|
||||
}
|
||||
|
||||
private NatsHeaders BuildHeaders(NotifyQueueEventMessage message, string idempotencyKey)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.EventId, message.Event.EventId.ToString("D") },
|
||||
{ NotifyQueueFields.Tenant, message.TenantId },
|
||||
{ NotifyQueueFields.Kind, message.Event.Kind },
|
||||
{ NotifyQueueFields.Attempt, "1" },
|
||||
{ NotifyQueueFields.EnqueuedAt, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, idempotencyKey }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, message.TraceId!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(message.PartitionKey))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.PartitionKey, message.PartitionKey!);
|
||||
}
|
||||
|
||||
foreach (var kvp in message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private NatsHeaders BuildDeadLetterHeaders(NatsNotifyEventLease lease, string reason)
|
||||
{
|
||||
var headers = new NatsHeaders
|
||||
{
|
||||
{ NotifyQueueFields.EventId, lease.Message.Event.EventId.ToString("D") },
|
||||
{ NotifyQueueFields.Tenant, lease.Message.TenantId },
|
||||
{ NotifyQueueFields.Kind, lease.Message.Event.Kind },
|
||||
{ NotifyQueueFields.Attempt, lease.Attempt.ToString(CultureInfo.InvariantCulture) },
|
||||
{ NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey },
|
||||
{ "deadletter-reason", reason }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.Message.TraceId))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.TraceId, lease.Message.TraceId!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lease.Message.PartitionKey))
|
||||
{
|
||||
headers.Add(NotifyQueueFields.PartitionKey, lease.Message.PartitionKey!);
|
||||
}
|
||||
|
||||
foreach (var kvp in lease.Message.Attributes)
|
||||
{
|
||||
headers.Add(NotifyQueueFields.AttributePrefix + kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static string? TryGetHeader(NatsHeaders headers, string key)
|
||||
{
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
var value = values[0];
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ExtractAttributes(NatsHeaders headers)
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in headers.Keys)
|
||||
{
|
||||
if (!key.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(key, out var values) && values.Count > 0)
|
||||
{
|
||||
attributes[key[NotifyQueueFields.AttributePrefix.Length..]] = values[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
return attributes.Count == 0
|
||||
? EmptyReadOnlyDictionary<string, string>.Instance
|
||||
: new ReadOnlyDictionary<string, string>(attributes);
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryInitialBackoff
|
||||
: _options.RetryDelay;
|
||||
|
||||
if (initial <= TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (attempt <= 1)
|
||||
{
|
||||
return initial;
|
||||
}
|
||||
|
||||
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
|
||||
? _queueOptions.RetryMaxBackoff
|
||||
: initial;
|
||||
|
||||
var exponent = attempt - 1;
|
||||
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
|
||||
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
|
||||
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
|
||||
return TimeSpan.FromTicks(resultTicks);
|
||||
}
|
||||
|
||||
private static long ToNanoseconds(TimeSpan value)
|
||||
=> value <= TimeSpan.Zero ? 0 : value.Ticks * 100L;
|
||||
|
||||
private 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
@@ -0,0 +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));
|
||||
}
|
||||
@@ -0,0 +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:";
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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)
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user