up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

@@ -13,6 +13,10 @@ public enum NotifyChannelType
Email,
Webhook,
Custom,
PagerDuty,
OpsGenie,
InApp,
Cli,
}
/// <summary>

View File

@@ -21,6 +21,7 @@ internal sealed class EnsureNotifyIndexesMigration : INotifyMongoMigration
await EnsureDigestsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureLocksIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureIncidentsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureRulesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
@@ -162,4 +163,35 @@ internal sealed class EnsureNotifyIndexesMigration : INotifyMongoMigration
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureIncidentsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.IncidentsCollection);
// Tenant + status + time for filtering
var statusKeys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("status")
.Descending("lastOccurrence");
var statusModel = new CreateIndexModel<BsonDocument>(statusKeys, new CreateIndexOptions
{
Name = "tenant_status_lastOccurrence"
});
await collection.Indexes.CreateOneAsync(statusModel, cancellationToken: cancellationToken).ConfigureAwait(false);
// Tenant + correlation key for fast lookup
var correlationKeys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("correlationKey")
.Ascending("status");
var correlationModel = new CreateIndexModel<BsonDocument>(correlationKeys, new CreateIndexOptions
{
Name = "tenant_correlationKey_status"
});
await collection.Indexes.CreateOneAsync(correlationModel, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -16,14 +16,16 @@ public sealed class NotifyMongoOptions
public string DeliveriesCollection { get; set; } = "deliveries";
public string DigestsCollection { get; set; } = "digests";
public string PackApprovalsCollection { get; set; } = "pack_approvals";
public string LocksCollection { get; set; } = "locks";
public string DigestsCollection { get; set; } = "digests";
public string PackApprovalsCollection { get; set; } = "pack_approvals";
public string LocksCollection { get; set; } = "locks";
public string AuditCollection { get; set; } = "audit";
public string IncidentsCollection { get; set; } = "incidents";
public string MigrationsCollection { get; set; } = "_notify_migrations";
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);

View File

@@ -0,0 +1,51 @@
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
/// <summary>
/// Repository for persisting and querying notification incidents.
/// </summary>
public interface INotifyIncidentRepository
{
/// <summary>
/// Upserts an incident.
/// </summary>
Task UpsertAsync(IncidentState incident, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an incident by ID.
/// </summary>
Task<IncidentState?> GetAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an incident by correlation key.
/// </summary>
Task<IncidentState?> GetByCorrelationKeyAsync(
string tenantId,
string correlationKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists incidents for a tenant with optional filtering.
/// </summary>
Task<NotifyIncidentQueryResult> QueryAsync(
string tenantId,
IncidentStatus? status = null,
DateTimeOffset? since = null,
DateTimeOffset? until = null,
int limit = 100,
string? continuationToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes an incident (soft delete).
/// </summary>
Task DeleteAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an incident query with pagination support.
/// </summary>
public sealed record NotifyIncidentQueryResult(
IReadOnlyList<IncidentState> Items,
string? ContinuationToken);

View File

@@ -0,0 +1,171 @@
using System.Globalization;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyIncidentRepository : INotifyIncidentRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
public NotifyIncidentRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.IncidentsCollection);
}
public async Task UpsertAsync(IncidentState incident, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(incident);
var document = NotifyIncidentDocumentMapper.ToBsonDocument(incident);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(incident.TenantId, incident.IncidentId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentState?> GetAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, incidentId))
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyIncidentDocumentMapper.FromBsonDocument(document);
}
public async Task<IncidentState?> GetByCorrelationKeyAsync(
string tenantId,
string correlationKey,
CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Eq("correlationKey", correlationKey)
& Builders<BsonDocument>.Filter.Ne("status", "resolved")
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var document = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Descending("lastOccurrence"))
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyIncidentDocumentMapper.FromBsonDocument(document);
}
public async Task<NotifyIncidentQueryResult> QueryAsync(
string tenantId,
IncidentStatus? status = null,
DateTimeOffset? since = null,
DateTimeOffset? until = null,
int limit = 100,
string? continuationToken = null,
CancellationToken cancellationToken = default)
{
var builder = Builders<BsonDocument>.Filter;
var filter = builder.Eq("tenantId", tenantId)
& builder.Or(
builder.Exists("deletedAt", false),
builder.Eq("deletedAt", BsonNull.Value));
if (status.HasValue)
{
filter &= builder.Eq("status", status.Value.ToString().ToLowerInvariant());
}
if (since.HasValue)
{
filter &= builder.Gte("lastOccurrence", since.Value.UtcDateTime);
}
if (until.HasValue)
{
filter &= builder.Lte("lastOccurrence", until.Value.UtcDateTime);
}
if (!string.IsNullOrWhiteSpace(continuationToken) &&
TryParseContinuationToken(continuationToken, out var continuationTime, out var continuationId))
{
var lessThanTime = builder.Lt("lastOccurrence", continuationTime);
var equalTimeLowerId = builder.And(builder.Eq("lastOccurrence", continuationTime), builder.Lte("_id", continuationId));
filter &= builder.Or(lessThanTime, equalTimeLowerId);
}
var documents = await _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Descending("lastOccurrence").Descending("_id"))
.Limit(limit + 1)
.ToListAsync(cancellationToken).ConfigureAwait(false);
string? nextToken = null;
if (documents.Count > limit)
{
var overflow = documents[^1];
documents.RemoveAt(documents.Count - 1);
nextToken = BuildContinuationToken(overflow);
}
var incidents = documents.Select(NotifyIncidentDocumentMapper.FromBsonDocument).ToArray();
return new NotifyIncidentQueryResult(incidents, nextToken);
}
public async Task DeleteAsync(string tenantId, string incidentId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, incidentId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
private static string BuildContinuationToken(BsonDocument document)
{
if (!document.TryGetValue("lastOccurrence", out var timeValue) || !timeValue.IsValidDateTime)
{
throw new InvalidOperationException("Incident document missing valid lastOccurrence for continuation token.");
}
if (!document.TryGetValue("_id", out var idValue) || !idValue.IsString)
{
throw new InvalidOperationException("Incident document missing string _id for continuation token.");
}
return FormattableString.Invariant($"{timeValue.ToUniversalTime():O}|{idValue.AsString}");
}
private static bool TryParseContinuationToken(string token, out DateTime time, out string id)
{
time = default;
id = string.Empty;
var parts = token.Split('|', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return false;
}
if (!DateTime.TryParseExact(parts[0], "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedTime))
{
return false;
}
if (string.IsNullOrWhiteSpace(parts[1]))
{
return false;
}
time = parsedTime.ToUniversalTime();
id = parts[1];
return true;
}
}

View File

@@ -0,0 +1,110 @@
using MongoDB.Bson;
using StellaOps.Notifier.Worker.Correlation;
namespace StellaOps.Notify.Storage.Mongo.Serialization;
/// <summary>
/// Maps IncidentState to and from BsonDocument.
/// </summary>
internal static class NotifyIncidentDocumentMapper
{
public static BsonDocument ToBsonDocument(IncidentState incident)
{
ArgumentNullException.ThrowIfNull(incident);
var document = new BsonDocument
{
["_id"] = $"{incident.TenantId}:{incident.IncidentId}",
["incidentId"] = incident.IncidentId,
["tenantId"] = incident.TenantId,
["correlationKey"] = incident.CorrelationKey,
["eventKind"] = incident.EventKind,
["title"] = incident.Title,
["status"] = incident.Status.ToString().ToLowerInvariant(),
["eventCount"] = incident.EventCount,
["firstOccurrence"] = incident.FirstOccurrence.UtcDateTime,
["lastOccurrence"] = incident.LastOccurrence.UtcDateTime,
["eventIds"] = new BsonArray(incident.EventIds)
};
if (incident.AcknowledgedBy is not null)
{
document["acknowledgedBy"] = incident.AcknowledgedBy;
}
if (incident.AcknowledgedAt.HasValue)
{
document["acknowledgedAt"] = incident.AcknowledgedAt.Value.UtcDateTime;
}
if (incident.AcknowledgeComment is not null)
{
document["acknowledgeComment"] = incident.AcknowledgeComment;
}
if (incident.ResolvedBy is not null)
{
document["resolvedBy"] = incident.ResolvedBy;
}
if (incident.ResolvedAt.HasValue)
{
document["resolvedAt"] = incident.ResolvedAt.Value.UtcDateTime;
}
if (incident.ResolutionReason is not null)
{
document["resolutionReason"] = incident.ResolutionReason;
}
return document;
}
public static IncidentState FromBsonDocument(BsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var eventIds = new List<string>();
if (document.TryGetValue("eventIds", out var eventIdsValue) && eventIdsValue.IsBsonArray)
{
eventIds.AddRange(eventIdsValue.AsBsonArray.Select(v => v.AsString));
}
var status = ParseStatus(document.GetValue("status", "open").AsString);
return new IncidentState
{
IncidentId = document["incidentId"].AsString,
TenantId = document["tenantId"].AsString,
CorrelationKey = document["correlationKey"].AsString,
EventKind = document["eventKind"].AsString,
Title = document["title"].AsString,
Status = status,
EventCount = document["eventCount"].AsInt32,
FirstOccurrence = new DateTimeOffset(document["firstOccurrence"].ToUniversalTime(), TimeSpan.Zero),
LastOccurrence = new DateTimeOffset(document["lastOccurrence"].ToUniversalTime(), TimeSpan.Zero),
EventIds = eventIds,
AcknowledgedBy = document.TryGetValue("acknowledgedBy", out var ackBy) ? ackBy.AsString : null,
AcknowledgedAt = document.TryGetValue("acknowledgedAt", out var ackAt) && ackAt.IsValidDateTime
? new DateTimeOffset(ackAt.ToUniversalTime(), TimeSpan.Zero)
: null,
AcknowledgeComment = document.TryGetValue("acknowledgeComment", out var ackComment) ? ackComment.AsString : null,
ResolvedBy = document.TryGetValue("resolvedBy", out var resBy) ? resBy.AsString : null,
ResolvedAt = document.TryGetValue("resolvedAt", out var resAt) && resAt.IsValidDateTime
? new DateTimeOffset(resAt.ToUniversalTime(), TimeSpan.Zero)
: null,
ResolutionReason = document.TryGetValue("resolutionReason", out var resReason) ? resReason.AsString : null
};
}
private static IncidentStatus ParseStatus(string status)
{
return status.ToLowerInvariant() switch
{
"open" => IncidentStatus.Open,
"acknowledged" => IncidentStatus.Acknowledged,
"resolved" => IncidentStatus.Resolved,
_ => IncidentStatus.Open
};
}
}

View File

@@ -27,6 +27,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<INotifyDigestRepository, NotifyDigestRepository>();
services.AddSingleton<INotifyLockRepository, NotifyLockRepository>();
services.AddSingleton<INotifyAuditRepository, NotifyAuditRepository>();
services.AddSingleton<INotifyIncidentRepository, NotifyIncidentRepository>();
return services;
}