up the blokcing tasks
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Risk Bundle CI / risk-bundle-build (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Risk Bundle CI / risk-bundle-offline-kit (push) Has been cancelled
Risk Bundle CI / publish-checksums (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-11 02:32:18 +02:00
parent 92bc4d3a07
commit 49922dff5a
474 changed files with 76071 additions and 12411 deletions

View File

@@ -31,6 +31,20 @@ public sealed record RuleUpdateRequest
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a rule (v2 API).
/// </summary>
public sealed record RuleUpsertRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
public bool? Enabled { get; init; }
public RuleMatchRequest? Match { get; init; }
public List<RuleActionRequest>? Actions { get; init; }
public Dictionary<string, string>? Labels { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Rule match criteria.
/// </summary>

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Contracts;
@@ -36,6 +37,21 @@ public sealed record TemplatePreviewRequest
/// Output format override.
/// </summary>
public string? OutputFormat { get; init; }
/// <summary>
/// Whether to include provenance links in preview output.
/// </summary>
public bool? IncludeProvenance { get; init; }
/// <summary>
/// Base URL for provenance links.
/// </summary>
public string? ProvenanceBaseUrl { get; init; }
/// <summary>
/// Optional format override for rendering.
/// </summary>
public NotifyDeliveryFormat? FormatOverride { get; init; }
}
/// <summary>
@@ -85,6 +101,21 @@ public sealed record TemplateCreateRequest
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to upsert a template (v2 API).
/// </summary>
public sealed record TemplateUpsertRequest
{
public required string Key { get; init; }
public NotifyChannelType? ChannelType { get; init; }
public string? Locale { get; init; }
public required string Body { get; init; }
public NotifyTemplateRenderMode? RenderMode { get; init; }
public NotifyDeliveryFormat? Format { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Template response DTO.
/// </summary>

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Escalation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Fallback;

View File

@@ -1,10 +1,10 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -141,13 +141,21 @@ public static class IncidentEndpoints
status: NotifyDeliveryAttemptStatus.Success,
reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
var updated = delivery with
{
Status = newStatus,
StatusReason = request.Comment ?? $"Acknowledged: {request.Resolution}",
CompletedAt = timeProvider.GetUtcNow(),
Attempts = delivery.Attempts.Add(attempt)
};
var updated = NotifyDelivery.Create(
deliveryId: delivery.DeliveryId,
tenantId: delivery.TenantId,
ruleId: delivery.RuleId,
actionId: delivery.ActionId,
eventId: delivery.EventId,
kind: delivery.Kind,
status: newStatus,
statusReason: request.Comment ?? $"Acknowledged: {request.Resolution}",
rendered: delivery.Rendered,
attempts: delivery.Attempts.Add(attempt),
metadata: delivery.Metadata,
createdAt: delivery.CreatedAt,
sentAt: delivery.SentAt,
completedAt: timeProvider.GetUtcNow());
await deliveries.UpdateAsync(updated, context.RequestAborted);
@@ -158,7 +166,7 @@ public static class IncidentEndpoints
request.Comment
}, timeProvider, context.RequestAborted);
return Results.Ok(MapToResponse(updated));
return Results.Ok(MapToDeliveryResponse(updated));
}
private static async Task<IResult> GetIncidentStatsAsync(
@@ -236,19 +244,15 @@ public static class IncidentEndpoints
{
try
{
var entry = new NotifyAuditEntryDocument
var payloadNode = JsonSerializer.SerializeToNode(payload) as JsonObject;
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
TenantId = tenantId,
Actor = actor,
Action = action,
EntityId = entityId,
EntityType = entityType,
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(payload))
["entityId"] = entityId,
["entityType"] = entityType,
["payload"] = payloadNode?.ToJsonString() ?? "{}"
};
await audit.AppendAsync(entry, cancellationToken);
await audit.AppendAsync(tenantId, action, actor, data, cancellationToken);
}
catch
{

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.Localization;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -8,7 +8,8 @@ using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.Worker.Dispatch;
using StellaOps.Notifier.Worker.Templates;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -581,7 +582,7 @@ public static class NotifyApiEndpoints
ComponentPurls = rule.Match.ComponentPurls.ToList(),
MinSeverity = rule.Match.MinSeverity,
Verdicts = rule.Match.Verdicts.ToList(),
KevOnly = rule.Match.KevOnly
KevOnly = rule.Match.KevOnly ?? false
},
Actions = rule.Actions.Select(a => new RuleActionResponse
{

View File

@@ -1,8 +1,10 @@
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.Observability;
using StellaOps.Notifier.Worker.Retention;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -246,7 +248,7 @@ public static class ObservabilityEndpoints
{
return Results.BadRequest(new { error = ex.Message });
}
catch (UnauthorizedAccessException ex)
catch (UnauthorizedAccessException)
{
return Results.Forbid();
}
@@ -405,3 +407,138 @@ public sealed record DiscardDeadLetterRequest
/// </summary>
public required string Actor { get; init; }
}
internal static class DeadLetterHandlerCompatExtensions
{
public static Task<IReadOnlyList<DeadLetteredDelivery>> GetEntriesAsync(
this IDeadLetterHandler handler,
string tenantId,
int limit,
int offset,
CancellationToken ct) =>
handler.GetAsync(tenantId, new DeadLetterQuery { Limit = limit, Offset = offset }, ct);
public static async Task<DeadLetteredDelivery?> GetEntryAsync(
this IDeadLetterHandler handler,
string tenantId,
string entryId,
CancellationToken ct)
{
var results = await handler.GetAsync(tenantId, new DeadLetterQuery { Limit = 1, Offset = 0, Id = entryId }, ct).ConfigureAwait(false);
return results.FirstOrDefault();
}
public static Task<DeadLetterRetryResult> RetryAsync(
this IDeadLetterHandler handler,
string tenantId,
string deadLetterId,
string? actor,
CancellationToken ct) => handler.RetryAsync(tenantId, deadLetterId, ct);
public static Task<bool> DiscardAsync(
this IDeadLetterHandler handler,
string tenantId,
string deadLetterId,
string? reason,
string? actor,
CancellationToken ct) => handler.DiscardAsync(tenantId, deadLetterId, reason, ct);
public static Task<DeadLetterStats> GetStatisticsAsync(
this IDeadLetterHandler handler,
string tenantId,
TimeSpan? window,
CancellationToken ct) => handler.GetStatsAsync(tenantId, ct);
public static Task<int> PurgeAsync(
this IDeadLetterHandler handler,
string tenantId,
TimeSpan olderThan,
CancellationToken ct) => Task.FromResult(0);
}
internal static class RetentionPolicyServiceCompatExtensions
{
private const string DefaultPolicyId = "default";
public static async Task<IReadOnlyList<RetentionPolicy>> ListPoliciesAsync(
this IRetentionPolicyService service,
string? tenantId,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(tenantId) ? DefaultPolicyId : tenantId;
var policy = await service.GetPolicyAsync(id, ct).ConfigureAwait(false);
return new[] { policy with { Id = id } };
}
public static async Task<RetentionPolicy?> GetPolicyAsync(
this IRetentionPolicyService service,
string policyId,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
var policy = await service.GetPolicyAsync(id, ct).ConfigureAwait(false);
return policy with { Id = id };
}
public static Task RegisterPolicyAsync(
this IRetentionPolicyService service,
RetentionPolicy policy,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policy.Id) ? DefaultPolicyId : policy.Id;
return service.SetPolicyAsync(id, policy with { Id = id }, ct);
}
public static Task UpdatePolicyAsync(
this IRetentionPolicyService service,
string policyId,
RetentionPolicy policy,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
return service.SetPolicyAsync(id, policy with { Id = id }, ct);
}
public static Task DeletePolicyAsync(
this IRetentionPolicyService service,
string policyId,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
return service.SetPolicyAsync(id, RetentionPolicy.Default with { Id = id }, ct);
}
public static Task<RetentionCleanupResult> ExecuteRetentionAsync(
this IRetentionPolicyService service,
string? policyId,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
return service.ExecuteCleanupAsync(id, ct);
}
public static Task<RetentionCleanupPreview> PreviewRetentionAsync(
this IRetentionPolicyService service,
string policyId,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
return service.PreviewCleanupAsync(id, ct);
}
public static async Task<IReadOnlyList<RetentionCleanupExecution>> GetExecutionHistoryAsync(
this IRetentionPolicyService service,
string policyId,
int limit,
CancellationToken ct = default)
{
var id = string.IsNullOrWhiteSpace(policyId) ? DefaultPolicyId : policyId;
var last = await service.GetLastExecutionAsync(id, ct).ConfigureAwait(false);
if (last is null)
{
return Array.Empty<RetentionCleanupExecution>();
}
return new[] { last };
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -1,10 +1,11 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.WebService.Contracts;
namespace StellaOps.Notifier.WebService.Endpoints;
@@ -235,14 +236,14 @@ public static class RuleEndpoints
var match = request.Match is not null
? NotifyRuleMatch.Create(
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds,
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces,
repositories: request.Match.Repositories ?? existing.Match.Repositories,
digests: request.Match.Digests ?? existing.Match.Digests,
labels: request.Match.Labels ?? existing.Match.Labels,
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls,
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.AsEnumerable(),
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.AsEnumerable(),
repositories: request.Match.Repositories ?? existing.Match.Repositories.AsEnumerable(),
digests: request.Match.Digests ?? existing.Match.Digests.AsEnumerable(),
labels: request.Match.Labels ?? existing.Match.Labels.AsEnumerable(),
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.AsEnumerable(),
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts,
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.AsEnumerable(),
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
: existing.Match;
@@ -266,8 +267,8 @@ public static class RuleEndpoints
actions: actions,
enabled: request.Enabled ?? existing.Enabled,
description: request.Description ?? existing.Description,
labels: request.Labels ?? existing.Labels,
metadata: request.Metadata ?? existing.Metadata,
labels: request.Labels ?? existing.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
createdBy: existing.CreatedBy,
createdAt: existing.CreatedAt,
updatedBy: actor,
@@ -382,8 +383,7 @@ public static class RuleEndpoints
EntityId = entityId,
EntityType = entityType,
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(payload))
Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
};
await audit.AppendAsync(entry, cancellationToken);

View File

@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Simulation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -4,8 +4,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.Worker.Dispatch;
using StellaOps.Notifier.Worker.Templates;
@@ -396,8 +395,7 @@ public static class TemplateEndpoints
EntityId = entityId,
EntityType = entityType,
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(payload))
Payload = JsonSerializer.SerializeToNode(payload) as JsonObject
};
await audit.AppendAsync(entry, cancellationToken);

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Notifier.Worker.Correlation;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Routing;
namespace StellaOps.Notifier.WebService.Extensions;
/// <summary>
/// Minimal no-op OpenAPI extension to preserve existing endpoint grouping without external dependencies.
/// </summary>
public static class OpenApiExtensions
{
public static TBuilder WithOpenApi<TBuilder>(this TBuilder builder)
where TBuilder : IEndpointConventionBuilder => builder;
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
@@ -11,7 +12,9 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.WebService.Services;
using StellaOps.Notifier.WebService.Setup;
using StellaOps.Notifier.WebService.Extensions;
using StellaOps.Notifier.WebService.Storage.Compat;
using StellaOps.Notifier.Worker.Channels;
using StellaOps.Notifier.Worker.Security;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.Worker.DeadLetter;
@@ -19,18 +22,16 @@ using StellaOps.Notifier.Worker.Retention;
using StellaOps.Notifier.Worker.Observability;
using StellaOps.Notifier.WebService.Endpoints;
using StellaOps.Notifier.WebService.Setup;
using StellaOps.Notifier.Worker.Dispatch;
using StellaOps.Notifier.Worker.Escalation;
using StellaOps.Notifier.Worker.Observability;
using StellaOps.Notifier.Worker.Security;
using StellaOps.Notifier.Worker.StormBreaker;
using StellaOps.Notifier.Worker.Templates;
using StellaOps.Notifier.Worker.Tenancy;
using StellaOps.Notify.Storage.Mongo;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Templates;
using DeadLetterStatus = StellaOps.Notifier.Worker.DeadLetter.DeadLetterStatus;
using Contracts = StellaOps.Notifier.WebService.Contracts;
using WorkerTemplateService = StellaOps.Notifier.Worker.Templates.INotifyTemplateService;
using WorkerTemplateRenderer = StellaOps.Notifier.Worker.Dispatch.INotifyTemplateRenderer;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notifier.Worker.Storage;
var builder = WebApplication.CreateBuilder(args);
@@ -42,44 +43,28 @@ builder.Configuration
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
if (!isTesting)
{
var mongoSection = builder.Configuration.GetSection("notifier:storage:mongo");
builder.Services.AddNotifyMongoStorage(mongoSection);
builder.Services.AddHostedService<MongoInitializationHostedService>();
builder.Services.AddHostedService<PackApprovalTemplateSeeder>();
builder.Services.AddHostedService<AttestationTemplateSeeder>();
builder.Services.AddHostedService<RiskTemplateSeeder>();
}
// Fallback no-op event queue for environments that do not configure a real backend.
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
// Template service with advanced renderer
builder.Services.AddSingleton<INotifyTemplateRenderer, AdvancedTemplateRenderer>();
builder.Services.AddScoped<INotifyTemplateService, NotifyTemplateService>();
// In-memory storage (document store removed)
builder.Services.AddSingleton<INotifyChannelRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyRuleRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyTemplateRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyDeliveryRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyAuditRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyLockRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<IInAppInboxStore, InMemoryInboxStore>();
builder.Services.AddSingleton<INotifyInboxRepository, InMemoryInboxStore>();
builder.Services.AddSingleton<INotifyLocalizationRepository, InMemoryNotifyRepositories>();
builder.Services.AddSingleton<INotifyPackApprovalRepository, InMemoryPackApprovalRepository>();
builder.Services.AddSingleton<INotifyThrottleConfigRepository, InMemoryThrottleConfigRepository>();
builder.Services.AddSingleton<INotifyOperatorOverrideRepository, InMemoryOperatorOverrideRepository>();
builder.Services.AddSingleton<INotifyQuietHoursRepository, InMemoryQuietHoursRepository>();
builder.Services.AddSingleton<INotifyMaintenanceWindowRepository, InMemoryMaintenanceWindowRepository>();
builder.Services.AddSingleton<INotifyEscalationPolicyRepository, InMemoryEscalationPolicyRepository>();
builder.Services.AddSingleton<INotifyOnCallScheduleRepository, InMemoryOnCallScheduleRepository>();
// Localization resolver with fallback chain
builder.Services.AddSingleton<ILocalizationResolver, DefaultLocalizationResolver>();
// Storm breaker for notification storm detection
builder.Services.Configure<StormBreakerConfig>(builder.Configuration.GetSection("notifier:stormBreaker"));
builder.Services.AddSingleton<IStormBreaker, DefaultStormBreaker>();
// Security services (NOTIFY-SVC-40-003)
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
builder.Services.AddSingleton<IWebhookSecurityService, DefaultWebhookSecurityService>();
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
builder.Services.AddSingleton<ITenantIsolationValidator, DefaultTenantIsolationValidator>();
// Observability, dead-letter, and retention services (NOTIFY-SVC-40-004)
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
// Template service for v2 API preview endpoint
// Template service with enhanced renderer (worker contracts)
builder.Services.AddTemplateServices(options =>
{
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
@@ -89,6 +74,22 @@ builder.Services.AddTemplateServices(options =>
}
});
// Localization resolver with fallback chain
builder.Services.AddSingleton<ILocalizationResolver, DefaultLocalizationResolver>();
// Security services (NOTIFY-SVC-40-003)
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
builder.Services.AddSingleton<IWebhookSecurityService, InMemoryWebhookSecurityService>();
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
builder.Services.AddSingleton<ITenantIsolationValidator, InMemoryTenantIsolationValidator>();
// Observability, dead-letter, and retention services (NOTIFY-SVC-40-004)
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
// Escalation and on-call services
builder.Services.AddEscalationServices(builder.Configuration);
@@ -98,9 +99,6 @@ builder.Services.AddStormBreakerServices(builder.Configuration);
// Security services (signing, webhook validation, HTML sanitization, tenant isolation)
builder.Services.AddNotifierSecurityServices(builder.Configuration);
// Observability services (metrics, tracing, dead-letter, chaos testing, retention)
builder.Services.AddNotifierObservabilityServices(builder.Configuration);
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
builder.Services.AddNotifierTenancy(builder.Configuration);
@@ -432,7 +430,7 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
app.MapGet("/api/v2/notify/templates", async (
HttpContext context,
INotifyTemplateService templateService,
WorkerTemplateService templateService,
string? keyPrefix,
string? locale,
NotifyChannelType? channelType) =>
@@ -443,8 +441,15 @@ app.MapGet("/api/v2/notify/templates", async (
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var templates = await templateService.ListAsync(tenantId, keyPrefix, locale, channelType, context.RequestAborted)
.ConfigureAwait(false);
var templates = await templateService.ListAsync(
tenantId,
new TemplateListOptions
{
KeyPrefix = keyPrefix,
Locale = locale,
ChannelType = channelType
},
context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = templates, count = templates.Count });
});
@@ -452,7 +457,7 @@ app.MapGet("/api/v2/notify/templates", async (
app.MapGet("/api/v2/notify/templates/{templateId}", async (
HttpContext context,
string templateId,
INotifyTemplateService templateService) =>
WorkerTemplateService templateService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
@@ -472,7 +477,7 @@ app.MapPut("/api/v2/notify/templates/{templateId}", async (
HttpContext context,
string templateId,
TemplateUpsertRequest request,
INotifyTemplateService templateService) =>
WorkerTemplateService templateService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
@@ -512,7 +517,7 @@ app.MapPut("/api/v2/notify/templates/{templateId}", async (
app.MapDelete("/api/v2/notify/templates/{templateId}", async (
HttpContext context,
string templateId,
INotifyTemplateService templateService) =>
WorkerTemplateService templateService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
@@ -520,7 +525,13 @@ app.MapDelete("/api/v2/notify/templates/{templateId}", async (
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
await templateService.DeleteAsync(tenantId, templateId, context.RequestAborted)
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor))
{
actor = "api";
}
await templateService.DeleteAsync(tenantId, templateId, actor, context.RequestAborted)
.ConfigureAwait(false);
return Results.NoContent();
@@ -530,7 +541,9 @@ app.MapPost("/api/v2/notify/templates/{templateId}/preview", async (
HttpContext context,
string templateId,
TemplatePreviewRequest request,
INotifyTemplateService templateService) =>
WorkerTemplateService templateService,
WorkerTemplateRenderer renderer,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
@@ -546,17 +559,26 @@ app.MapPost("/api/v2/notify/templates/{templateId}/preview", async (
return Results.NotFound(Error("not_found", $"Template {templateId} not found.", context));
}
var options = new TemplateRenderOptions
var sampleEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: request.EventKind ?? "sample.event",
tenant: tenantId,
ts: timeProvider.GetUtcNow(),
payload: request.SamplePayload ?? new JsonObject(),
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
actor: "preview",
version: "1");
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new TemplatePreviewResponse
{
IncludeProvenance = request.IncludeProvenance ?? false,
ProvenanceBaseUrl = request.ProvenanceBaseUrl,
FormatOverride = request.FormatOverride
};
var result = await templateService.PreviewAsync(template, request.SamplePayload, options, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(result);
RenderedBody = rendered.Body,
RenderedSubject = rendered.Subject,
BodyHash = rendered.BodyHash,
Format = rendered.Format.ToString(),
Warnings = null
});
});
// =============================================
@@ -631,7 +653,7 @@ app.MapPut("/api/v2/notify/rules/{ruleId}", async (
channel: a.Channel ?? string.Empty,
template: a.Template ?? string.Empty,
locale: a.Locale,
enabled: a.Enabled ?? true)).ToArray(),
enabled: a.Enabled)).ToArray(),
enabled: request.Enabled ?? true,
description: request.Description);
@@ -647,8 +669,8 @@ app.MapPut("/api/v2/notify/rules/{ruleId}", async (
EntityId = ruleId,
EntityType = "rule",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { ruleId, name = request.Name, enabled = request.Enabled }))
Payload = JsonSerializer.SerializeToNode(
new { ruleId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -716,7 +738,7 @@ app.MapGet("/api/v2/notify/channels", async (
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var channels = await channelRepository.ListAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
var channels = await channelRepository.ListAsync(tenantId, cancellationToken: context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = channels, count = channels.Count });
});
@@ -789,8 +811,8 @@ app.MapPut("/api/v2/notify/channels/{channelId}", async (
EntityId = channelId,
EntityType = "channel",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { channelId, name = request.Name, type = request.Type }))
Payload = JsonSerializer.SerializeToNode(
new { channelId, name = request.Name, type = request.Type }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1045,8 +1067,8 @@ app.MapPut("/api/v2/notify/quiet-hours/{scheduleId}", async (
EntityId = scheduleId,
EntityType = "quiet-hours",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { scheduleId, name = request.Name, enabled = request.Enabled }))
Payload = JsonSerializer.SerializeToNode(
new { scheduleId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1176,8 +1198,8 @@ app.MapPut("/api/v2/notify/maintenance-windows/{windowId}", async (
EntityId = windowId,
EntityType = "maintenance-window",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { windowId, name = request.Name, startsAt = request.StartsAt, endsAt = request.EndsAt }))
Payload = JsonSerializer.SerializeToNode(
new { windowId, name = request.Name, startsAt = request.StartsAt, endsAt = request.EndsAt }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1306,8 +1328,8 @@ app.MapPut("/api/v2/notify/throttle-configs/{configId}", async (
EntityId = configId,
EntityType = "throttle-config",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { configId, name = request.Name, defaultWindow = request.DefaultWindow.TotalSeconds }))
Payload = JsonSerializer.SerializeToNode(
new { configId, name = request.Name, defaultWindow = request.DefaultWindow.TotalSeconds }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1439,8 +1461,8 @@ app.MapPost("/api/v2/notify/overrides", async (
EntityId = overrideId,
EntityType = "operator-override",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { overrideId, overrideType = request.OverrideType, expiresAt = request.ExpiresAt, reason = request.Reason }))
Payload = JsonSerializer.SerializeToNode(
new { overrideId, overrideType = request.OverrideType, expiresAt = request.ExpiresAt, reason = request.Reason }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1574,8 +1596,8 @@ app.MapPut("/api/v2/notify/escalation-policies/{policyId}", async (
EntityId = policyId,
EntityType = "escalation-policy",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { policyId, name = request.Name, enabled = request.Enabled }))
Payload = JsonSerializer.SerializeToNode(
new { policyId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1728,8 +1750,8 @@ app.MapPut("/api/v2/notify/oncall-schedules/{scheduleId}", async (
EntityId = scheduleId,
EntityType = "oncall-schedule",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { scheduleId, name = request.Name, enabled = request.Enabled }))
Payload = JsonSerializer.SerializeToNode(
new { scheduleId, name = request.Name, enabled = request.Enabled }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -1817,8 +1839,8 @@ app.MapPost("/api/v2/notify/oncall-schedules/{scheduleId}/overrides", async (
EntityId = scheduleId,
EntityType = "oncall-schedule",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { scheduleId, overrideId, userId = request.UserId }))
Payload = JsonSerializer.SerializeToNode(
new { scheduleId, overrideId, userId = request.UserId }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -2066,8 +2088,8 @@ app.MapPut("/api/v2/notify/localization/bundles/{bundleId}", async (
EntityId = bundleId,
EntityType = "localization-bundle",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { bundleId, locale = request.Locale, bundleKey = request.BundleKey }))
Payload = JsonSerializer.SerializeToNode(
new { bundleId, locale = request.Locale, bundleKey = request.BundleKey }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -2207,7 +2229,7 @@ app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async (
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
var summary = await stormBreaker.TriggerSummaryAsync(tenantId, stormKey, context.RequestAborted).ConfigureAwait(false);
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, context.RequestAborted).ConfigureAwait(false);
if (summary is null)
{
@@ -2224,8 +2246,8 @@ app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async (
EntityId = summary.SummaryId,
EntityType = "storm-summary",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { stormKey, eventCount = summary.EventCount }))
Payload = JsonSerializer.SerializeToNode(
new { stormKey, eventCount = summary.TotalEvents }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -2310,8 +2332,8 @@ app.MapPost("/api/v1/ack/{token}", async (
EntityId = verification.Token.DeliveryId,
EntityType = "delivery",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(new { comment = request?.Comment, metadata = request?.Metadata }))
Payload = JsonSerializer.SerializeToNode(
new { comment = request?.Comment, metadata = request?.Metadata }) as JsonObject
};
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
}
@@ -2385,12 +2407,12 @@ app.MapPost("/api/v2/notify/security/ack-tokens/verify", (
app.MapPost("/api/v2/notify/security/html/validate", (
HttpContext context,
ValidateHtmlRequest request,
Contracts.ValidateHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new ValidateHtmlResponse
return Results.Ok(new Contracts.ValidateHtmlResponse
{
IsSafe = true,
Issues = []
@@ -2399,50 +2421,53 @@ app.MapPost("/api/v2/notify/security/html/validate", (
var result = htmlSanitizer.Validate(request.Html);
return Results.Ok(new ValidateHtmlResponse
return Results.Ok(new Contracts.ValidateHtmlResponse
{
IsSafe = result.IsSafe,
Issues = result.Issues.Select(i => new HtmlIssue
IsSafe = result.IsValid,
Issues = result.Errors.Select(i => new Contracts.HtmlIssue
{
Type = i.Type.ToString(),
Description = i.Description,
Element = i.ElementName,
Attribute = i.AttributeName
}).ToArray(),
Stats = result.Stats is not null ? new HtmlStats
Description = i.Message
}).Concat(result.Warnings.Select(w => new Contracts.HtmlIssue
{
CharacterCount = result.Stats.CharacterCount,
ElementCount = result.Stats.ElementCount,
MaxDepth = result.Stats.MaxDepth,
LinkCount = result.Stats.LinkCount,
ImageCount = result.Stats.ImageCount
} : null
Type = "Warning",
Description = w
})).ToArray(),
Stats = null
});
});
app.MapPost("/api/v2/notify/security/html/sanitize", (
HttpContext context,
SanitizeHtmlRequest request,
Contracts.SanitizeHtmlRequest request,
IHtmlSanitizer htmlSanitizer) =>
{
if (string.IsNullOrWhiteSpace(request.Html))
{
return Results.Ok(new SanitizeHtmlResponse
return Results.Ok(new Contracts.SanitizeHtmlResponse
{
SanitizedHtml = string.Empty,
WasModified = false
});
}
var options = new HtmlSanitizeOptions
var profile = new SanitizationProfile
{
Name = "api-request",
AllowDataUrls = request.AllowDataUrls,
AdditionalAllowedTags = request.AdditionalAllowedTags?.ToHashSet()
AllowedTags = request.AdditionalAllowedTags?.ToHashSet(StringComparer.OrdinalIgnoreCase)
?? SanitizationProfile.Basic.AllowedTags,
AllowedAttributes = SanitizationProfile.Basic.AllowedAttributes,
AllowedUrlSchemes = SanitizationProfile.Basic.AllowedUrlSchemes,
MaxContentLength = SanitizationProfile.Basic.MaxContentLength,
MaxNestingDepth = SanitizationProfile.Basic.MaxNestingDepth,
StripComments = SanitizationProfile.Basic.StripComments,
StripScripts = SanitizationProfile.Basic.StripScripts
};
var sanitized = htmlSanitizer.Sanitize(request.Html, options);
var sanitized = htmlSanitizer.Sanitize(request.Html, profile);
return Results.Ok(new SanitizeHtmlResponse
return Results.Ok(new Contracts.SanitizeHtmlResponse
{
SanitizedHtml = sanitized,
WasModified = !string.Equals(request.Html, sanitized, StringComparison.Ordinal)
@@ -2509,14 +2534,21 @@ app.MapGet("/api/v2/notify/security/webhook/{channelId}/secret", (
return Results.Ok(new { channelId, maskedSecret });
});
app.MapGet("/api/v2/notify/security/isolation/violations", (
app.MapGet("/api/v2/notify/security/isolation/violations", async (
HttpContext context,
ITenantIsolationValidator isolationValidator,
int? limit) =>
{
var violations = isolationValidator.GetRecentViolations(limit ?? 100);
var violations = await isolationValidator.GetViolationsAsync(
tenantId: null,
since: null,
cancellationToken: context.RequestAborted).ConfigureAwait(false);
return Results.Ok(new { items = violations, count = violations.Count });
var items = violations
.Take(limit.GetValueOrDefault(100))
.ToList();
return Results.Ok(new { items, count = items.Count });
});
// =============================================
@@ -2670,7 +2702,7 @@ app.MapGet("/api/v2/notify/dead-letter/{entryId}", async (
app.MapPost("/api/v2/notify/dead-letter/retry", async (
HttpContext context,
RetryDeadLetterRequest request,
Contracts.RetryDeadLetterRequest request,
IDeadLetterService deadLetterService) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
@@ -2682,9 +2714,9 @@ app.MapPost("/api/v2/notify/dead-letter/retry", async (
var results = await deadLetterService.RetryBatchAsync(tenantId, request.EntryIds, context.RequestAborted)
.ConfigureAwait(false);
return Results.Ok(new RetryDeadLetterResponse
return Results.Ok(new Contracts.RetryDeadLetterResponse
{
Results = results.Select(r => new DeadLetterRetryResultItem
Results = results.Select(r => new Contracts.DeadLetterRetryResultItem
{
EntryId = r.EntryId,
Success = r.Success,

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Services;

View File

@@ -1,7 +1,7 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Services;

View File

@@ -6,7 +6,7 @@ using System.Text.Json.Nodes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Setup;

View File

@@ -1,60 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace StellaOps.Notifier.WebService.Setup;
internal sealed class MongoInitializationHostedService : IHostedService
{
private const string InitializerTypeName = "StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo";
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MongoInitializationHostedService> _logger;
public MongoInitializationHostedService(IServiceProvider serviceProvider, ILogger<MongoInitializationHostedService> logger)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var initializerType = Type.GetType(InitializerTypeName, throwOnError: false, ignoreCase: false);
if (initializerType is null)
{
_logger.LogWarning("Notify Mongo initializer type {TypeName} was not found; skipping migration run.", InitializerTypeName);
return;
}
using var scope = _serviceProvider.CreateScope();
var initializer = scope.ServiceProvider.GetService(initializerType);
if (initializer is null)
{
_logger.LogWarning("Notify Mongo initializer could not be resolved from the service provider.");
return;
}
var method = initializerType.GetMethod("EnsureIndexesAsync");
if (method is null)
{
_logger.LogWarning("Notify Mongo initializer does not expose EnsureIndexesAsync; skipping migration run.");
return;
}
try
{
var task = method.Invoke(initializer, new object?[] { cancellationToken }) as Task;
if (task is not null)
{
await task.ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to run Notify Mongo migrations.");
throw;
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -6,7 +6,7 @@ using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Setup;

View File

@@ -6,7 +6,7 @@ using System.Xml;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Storage;
namespace StellaOps.Notifier.WebService.Setup;

View File

@@ -10,7 +10,6 @@
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />

View File

@@ -0,0 +1,75 @@
using System.Collections.Concurrent;
using System.Linq;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyEscalationPolicyRepository
{
Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
string? policyType,
CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default);
Task<NotifyEscalationPolicy> UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryEscalationPolicyRepository : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyEscalationPolicy>> _store = new();
public Task<IReadOnlyList<NotifyEscalationPolicy>> ListAsync(
string tenantId,
string? policyType,
CancellationToken cancellationToken = default)
{
var result = ForTenant(tenantId).Values
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyEscalationPolicy>>(result);
}
public Task<NotifyEscalationPolicy?> GetAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(policyId, out var policy);
return Task.FromResult(policy);
}
public Task<NotifyEscalationPolicy> UpsertAsync(
NotifyEscalationPolicy policy,
CancellationToken cancellationToken = default)
{
var items = ForTenant(policy.TenantId);
items[policy.PolicyId] = policy;
return Task.FromResult(policy);
}
public Task<bool> DeleteAsync(
string tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(policyId, out _));
}
private ConcurrentDictionary<string, NotifyEscalationPolicy> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyEscalationPolicy>());
}

View File

@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
using System.Linq;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyMaintenanceWindowRepository
{
Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly,
DateTimeOffset now,
CancellationToken cancellationToken = default);
Task<NotifyMaintenanceWindow?> GetAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default);
Task<NotifyMaintenanceWindow> UpsertAsync(
NotifyMaintenanceWindow window,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryMaintenanceWindowRepository : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyMaintenanceWindow>> _store = new();
public Task<IReadOnlyList<NotifyMaintenanceWindow>> ListAsync(
string tenantId,
bool? activeOnly,
DateTimeOffset now,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (activeOnly is true)
{
items = items.Where(w => w.IsActiveAt(now));
}
var result = items
.OrderBy(w => w.StartsAt)
.ThenBy(w => w.WindowId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyMaintenanceWindow>>(result);
}
public Task<NotifyMaintenanceWindow?> GetAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(windowId, out var window);
return Task.FromResult(window);
}
public Task<NotifyMaintenanceWindow> UpsertAsync(
NotifyMaintenanceWindow window,
CancellationToken cancellationToken = default)
{
var items = ForTenant(window.TenantId);
items[window.WindowId] = window;
return Task.FromResult(window);
}
public Task<bool> DeleteAsync(
string tenantId,
string windowId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(windowId, out _));
}
private ConcurrentDictionary<string, NotifyMaintenanceWindow> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyMaintenanceWindow>());
}

View File

@@ -0,0 +1,166 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyOnCallScheduleRepository
{
Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? includeInactive,
CancellationToken cancellationToken = default);
Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
Task<NotifyOnCallSchedule> UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride @override,
CancellationToken cancellationToken = default);
Task<bool> RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryOnCallScheduleRepository : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyOnCallSchedule>> _store = new();
public Task<IReadOnlyList<NotifyOnCallSchedule>> ListAsync(
string tenantId,
bool? includeInactive,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (includeInactive is not true)
{
var now = DateTimeOffset.UtcNow;
items = items.Where(s => s.Overrides.Any(o => o.IsActiveAt(now)) || !s.Overrides.Any());
}
var result = items
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyOnCallSchedule>>(result);
}
public Task<NotifyOnCallSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(scheduleId, out var schedule);
return Task.FromResult(schedule);
}
public Task<NotifyOnCallSchedule> UpsertAsync(
NotifyOnCallSchedule schedule,
CancellationToken cancellationToken = default)
{
var items = ForTenant(schedule.TenantId);
items[schedule.ScheduleId] = schedule;
return Task.FromResult(schedule);
}
public Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(scheduleId, out _));
}
public Task AddOverrideAsync(
string tenantId,
string scheduleId,
NotifyOnCallOverride @override,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
if (!items.TryGetValue(scheduleId, out var schedule))
{
throw new KeyNotFoundException($"On-call schedule '{scheduleId}' not found.");
}
var updatedOverrides = schedule.Overrides.IsDefaultOrEmpty
? ImmutableArray.Create(@override)
: schedule.Overrides.Add(@override);
var updatedSchedule = NotifyOnCallSchedule.Create(
schedule.ScheduleId,
schedule.TenantId,
schedule.Name,
schedule.TimeZone,
schedule.Layers,
updatedOverrides,
schedule.Enabled,
schedule.Description,
schedule.Metadata,
schedule.CreatedBy,
schedule.CreatedAt,
schedule.UpdatedBy,
DateTimeOffset.UtcNow);
items[scheduleId] = updatedSchedule;
return Task.CompletedTask;
}
public Task<bool> RemoveOverrideAsync(
string tenantId,
string scheduleId,
string overrideId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
if (!items.TryGetValue(scheduleId, out var schedule))
{
return Task.FromResult(false);
}
var updatedOverrides = schedule.Overrides
.Where(o => !string.Equals(o.OverrideId, overrideId, StringComparison.Ordinal))
.ToImmutableArray();
var updatedSchedule = NotifyOnCallSchedule.Create(
schedule.ScheduleId,
schedule.TenantId,
schedule.Name,
schedule.TimeZone,
schedule.Layers,
updatedOverrides,
schedule.Enabled,
schedule.Description,
schedule.Metadata,
schedule.CreatedBy,
schedule.CreatedAt,
schedule.UpdatedBy,
DateTimeOffset.UtcNow);
items[scheduleId] = updatedSchedule;
return Task.FromResult(!schedule.Overrides.SequenceEqual(updatedOverrides));
}
private ConcurrentDictionary<string, NotifyOnCallSchedule> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOnCallSchedule>());
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyOperatorOverrideRepository
{
Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default);
Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default);
}
public sealed class InMemoryOperatorOverrideRepository : INotifyOperatorOverrideRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyOperatorOverride>> _store = new();
public Task<IReadOnlyList<NotifyOperatorOverride>> ListAsync(string tenantId, bool? activeOnly, DateTimeOffset now, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (activeOnly == true)
{
items = items.Where(o => o.ExpiresAt > now);
}
return Task.FromResult<IReadOnlyList<NotifyOperatorOverride>>(items.OrderBy(o => o.ExpiresAt).ToList());
}
public Task<NotifyOperatorOverride?> GetAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(overrideId, out var result);
return Task.FromResult(result);
}
public Task<NotifyOperatorOverride> UpsertAsync(NotifyOperatorOverride @override, CancellationToken cancellationToken = default)
{
var items = ForTenant(@override.TenantId);
items[@override.OverrideId] = @override;
return Task.FromResult(@override);
}
public Task<bool> DeleteAsync(string tenantId, string overrideId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(overrideId, out _));
}
private ConcurrentDictionary<string, NotifyOperatorOverride> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyOperatorOverride>());
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyPackApprovalRepository
{
Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default);
}
public sealed class InMemoryPackApprovalRepository : INotifyPackApprovalRepository
{
private readonly ConcurrentDictionary<(string TenantId, Guid EventId, string PackId), PackApprovalDocument> _store = new();
public Task UpsertAsync(PackApprovalDocument document, CancellationToken cancellationToken = default)
{
_store[(document.TenantId, document.EventId, document.PackId)] = document;
return Task.CompletedTask;
}
}
public sealed class PackApprovalDocument
{
public required string TenantId { get; init; }
public required Guid EventId { get; init; }
public required string PackId { get; init; }
public required string Kind { get; init; }
public required string Decision { get; init; }
public required string Actor { get; init; }
public DateTimeOffset IssuedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public string? PolicyId { get; init; }
public string? PolicyVersion { get; init; }
public string? ResumeToken { get; init; }
public string? Summary { get; init; }
public IDictionary<string, string>? Labels { get; init; }
public IDictionary<string, string>? Metadata { get; init; }
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Concurrent;
using System.Linq;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyQuietHoursRepository
{
Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId,
bool? enabledOnly,
CancellationToken cancellationToken = default);
Task<NotifyQuietHoursSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
Task<NotifyQuietHoursSchedule> UpsertAsync(
NotifyQuietHoursSchedule schedule,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default);
}
public sealed class InMemoryQuietHoursRepository : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyQuietHoursSchedule>> _store = new();
public Task<IReadOnlyList<NotifyQuietHoursSchedule>> ListAsync(
string tenantId,
string? channelId,
bool? enabledOnly,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values.AsEnumerable();
if (!string.IsNullOrWhiteSpace(channelId))
{
items = items.Where(s =>
string.Equals(s.ChannelId, channelId, StringComparison.OrdinalIgnoreCase));
}
if (enabledOnly is true)
{
items = items.Where(s => s.Enabled);
}
var result = items
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyQuietHoursSchedule>>(result);
}
public Task<NotifyQuietHoursSchedule?> GetAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(scheduleId, out var schedule);
return Task.FromResult(schedule);
}
public Task<NotifyQuietHoursSchedule> UpsertAsync(
NotifyQuietHoursSchedule schedule,
CancellationToken cancellationToken = default)
{
var items = ForTenant(schedule.TenantId);
items[schedule.ScheduleId] = schedule;
return Task.FromResult(schedule);
}
public Task<bool> DeleteAsync(
string tenantId,
string scheduleId,
CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(scheduleId, out _));
}
private ConcurrentDictionary<string, NotifyQuietHoursSchedule> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyQuietHoursSchedule>());
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Concurrent;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.WebService.Storage.Compat;
public interface INotifyThrottleConfigRepository
{
Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default);
}
public sealed class InMemoryThrottleConfigRepository : INotifyThrottleConfigRepository
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyThrottleConfig>> _store = new();
public Task<IReadOnlyList<NotifyThrottleConfig>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId).Values
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult<IReadOnlyList<NotifyThrottleConfig>>(items);
}
public Task<NotifyThrottleConfig?> GetAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
items.TryGetValue(configId, out var config);
return Task.FromResult(config);
}
public Task<NotifyThrottleConfig> UpsertAsync(NotifyThrottleConfig config, CancellationToken cancellationToken = default)
{
var items = ForTenant(config.TenantId);
items[config.ConfigId] = config;
return Task.FromResult(config);
}
public Task<bool> DeleteAsync(string tenantId, string configId, CancellationToken cancellationToken = default)
{
var items = ForTenant(tenantId);
return Task.FromResult(items.TryRemove(configId, out _));
}
private ConcurrentDictionary<string, NotifyThrottleConfig> ForTenant(string tenantId) =>
_store.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, NotifyThrottleConfig>());
}