Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.WebService — Agent Charter
## Mission
Implement Notify control plane per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Worker — Agent Charter
## Mission
Consume events, evaluate rules, and dispatch deliveries per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
# Notify Worker Task Board (Sprint 15)
> Archived 2025-10-26 — worker responsibilities handled in `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View 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"
}
}
}
}

View 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

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Email — Agent Charter
## Mission
Implement SMTP connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
# Notify Email Connector Task Board (Sprint 15)
> Archived 2025-10-26 — connector maintained under `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Slack — Agent Charter
## Mission
Deliver Slack connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
# Notify Slack Connector Task Board (Sprint 15)
> Archived 2025-10-26 — connector scope now in `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

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

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Teams — Agent Charter
## Mission
Implement Microsoft Teams connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Connectors.Webhook — Agent Charter
## Mission
Implement generic webhook connector plug-in per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

@@ -0,0 +1,2 @@
# Notify Webhook Connector Task Board (Sprint 15)
> Archived 2025-10-26 — webhook connector maintained in `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Engine — Agent Charter
## Mission
Deliver rule evaluation, digest, and rendering logic per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
# Notify Engine Task Board (Sprint 15)
> Archived 2025-10-26 — runtime responsibilities moved to `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Models — Agent Charter
## Mission
Define Notify DTOs and contracts per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

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

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,2 @@
# Notify Models Task Board (Sprint 15)
> Archived 2025-10-26 — scope moved to `src/Notifier/StellaOps.Notifier` (Sprints 3840).

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Queue — Agent Charter
## Mission
Provide event & delivery queues for Notify per `docs/ARCHITECTURE_NOTIFY.md`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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