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
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:
@@ -13,6 +13,10 @@ public enum NotifyChannelType
|
||||
Email,
|
||||
Webhook,
|
||||
Custom,
|
||||
PagerDuty,
|
||||
OpsGenie,
|
||||
InApp,
|
||||
Cli,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user