up
This commit is contained in:
172
src/Policy/StellaOps.Policy.Engine/Events/ProfileEventModels.cs
Normal file
172
src/Policy/StellaOps.Policy.Engine/Events/ProfileEventModels.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.RiskProfile.Lifecycle;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for profile lifecycle events.
|
||||
/// </summary>
|
||||
public abstract record ProfileEvent(
|
||||
[property: JsonPropertyName("event_id")] string EventId,
|
||||
[property: JsonPropertyName("event_type")] ProfileEventType EventType,
|
||||
[property: JsonPropertyName("profile_id")] string ProfileId,
|
||||
[property: JsonPropertyName("profile_version")] string ProfileVersion,
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("actor")] string? Actor,
|
||||
[property: JsonPropertyName("correlation_id")] string? CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Type of profile event.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ProfileEventType>))]
|
||||
public enum ProfileEventType
|
||||
{
|
||||
[JsonPropertyName("profile_created")]
|
||||
ProfileCreated,
|
||||
|
||||
[JsonPropertyName("profile_published")]
|
||||
ProfilePublished,
|
||||
|
||||
[JsonPropertyName("profile_activated")]
|
||||
ProfileActivated,
|
||||
|
||||
[JsonPropertyName("profile_deprecated")]
|
||||
ProfileDeprecated,
|
||||
|
||||
[JsonPropertyName("profile_archived")]
|
||||
ProfileArchived,
|
||||
|
||||
[JsonPropertyName("severity_threshold_changed")]
|
||||
SeverityThresholdChanged,
|
||||
|
||||
[JsonPropertyName("weight_changed")]
|
||||
WeightChanged,
|
||||
|
||||
[JsonPropertyName("override_added")]
|
||||
OverrideAdded,
|
||||
|
||||
[JsonPropertyName("override_removed")]
|
||||
OverrideRemoved,
|
||||
|
||||
[JsonPropertyName("scope_attached")]
|
||||
ScopeAttached,
|
||||
|
||||
[JsonPropertyName("scope_detached")]
|
||||
ScopeDetached
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a profile is created.
|
||||
/// </summary>
|
||||
public sealed record ProfileCreatedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId,
|
||||
[property: JsonPropertyName("content_hash")] string ContentHash,
|
||||
[property: JsonPropertyName("description")] string? Description)
|
||||
: ProfileEvent(EventId, ProfileEventType.ProfileCreated, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a profile is published/activated.
|
||||
/// </summary>
|
||||
public sealed record ProfilePublishedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId,
|
||||
[property: JsonPropertyName("content_hash")] string ContentHash,
|
||||
[property: JsonPropertyName("previous_active_version")] string? PreviousActiveVersion)
|
||||
: ProfileEvent(EventId, ProfileEventType.ProfilePublished, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a profile is deprecated.
|
||||
/// </summary>
|
||||
public sealed record ProfileDeprecatedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId,
|
||||
[property: JsonPropertyName("reason")] string? Reason,
|
||||
[property: JsonPropertyName("successor_version")] string? SuccessorVersion)
|
||||
: ProfileEvent(EventId, ProfileEventType.ProfileDeprecated, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a profile is archived.
|
||||
/// </summary>
|
||||
public sealed record ProfileArchivedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId)
|
||||
: ProfileEvent(EventId, ProfileEventType.ProfileArchived, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when severity thresholds change.
|
||||
/// </summary>
|
||||
public sealed record SeverityThresholdChangedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId,
|
||||
[property: JsonPropertyName("changes")] IReadOnlyList<ThresholdChange> Changes)
|
||||
: ProfileEvent(EventId, ProfileEventType.SeverityThresholdChanged, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Details of a threshold change.
|
||||
/// </summary>
|
||||
public sealed record ThresholdChange(
|
||||
[property: JsonPropertyName("threshold_name")] string ThresholdName,
|
||||
[property: JsonPropertyName("old_value")] double? OldValue,
|
||||
[property: JsonPropertyName("new_value")] double? NewValue);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when weights change.
|
||||
/// </summary>
|
||||
public sealed record WeightChangedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId,
|
||||
[property: JsonPropertyName("signal_name")] string SignalName,
|
||||
[property: JsonPropertyName("old_weight")] double OldWeight,
|
||||
[property: JsonPropertyName("new_weight")] double NewWeight)
|
||||
: ProfileEvent(EventId, ProfileEventType.WeightChanged, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when a scope is attached.
|
||||
/// </summary>
|
||||
public sealed record ScopeAttachedEvent(
|
||||
string EventId,
|
||||
string ProfileId,
|
||||
string ProfileVersion,
|
||||
DateTimeOffset Timestamp,
|
||||
string? Actor,
|
||||
string? CorrelationId,
|
||||
[property: JsonPropertyName("scope_type")] string ScopeType,
|
||||
[property: JsonPropertyName("scope_id")] string ScopeId,
|
||||
[property: JsonPropertyName("attachment_id")] string AttachmentId)
|
||||
: ProfileEvent(EventId, ProfileEventType.ScopeAttached, ProfileId, ProfileVersion, Timestamp, Actor, CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Event subscription request.
|
||||
/// </summary>
|
||||
public sealed record EventSubscription(
|
||||
[property: JsonPropertyName("subscription_id")] string SubscriptionId,
|
||||
[property: JsonPropertyName("event_types")] IReadOnlyList<ProfileEventType> EventTypes,
|
||||
[property: JsonPropertyName("profile_filter")] string? ProfileFilter,
|
||||
[property: JsonPropertyName("webhook_url")] string? WebhookUrl,
|
||||
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("created_by")] string? CreatedBy);
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Service for publishing and managing profile lifecycle events.
|
||||
/// </summary>
|
||||
public sealed class ProfileEventPublisher
|
||||
{
|
||||
private readonly ILogger<ProfileEventPublisher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, EventSubscription> _subscriptions;
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<ProfileEvent>> _eventQueues;
|
||||
private readonly ConcurrentQueue<ProfileEvent> _globalEventStream;
|
||||
private readonly List<Func<ProfileEvent, Task>> _eventHandlers;
|
||||
private readonly object _handlersLock = new();
|
||||
|
||||
private const int MaxEventsPerQueue = 10000;
|
||||
private const int MaxGlobalEvents = 50000;
|
||||
|
||||
public ProfileEventPublisher(
|
||||
ILogger<ProfileEventPublisher> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_subscriptions = new ConcurrentDictionary<string, EventSubscription>(StringComparer.OrdinalIgnoreCase);
|
||||
_eventQueues = new ConcurrentDictionary<string, ConcurrentQueue<ProfileEvent>>(StringComparer.OrdinalIgnoreCase);
|
||||
_globalEventStream = new ConcurrentQueue<ProfileEvent>();
|
||||
_eventHandlers = new List<Func<ProfileEvent, Task>>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a profile created event.
|
||||
/// </summary>
|
||||
public async Task PublishProfileCreatedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
string contentHash,
|
||||
string? description,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new ProfileCreatedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
ContentHash: contentHash,
|
||||
Description: description);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a profile published/activated event.
|
||||
/// </summary>
|
||||
public async Task PublishProfilePublishedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
string contentHash,
|
||||
string? previousActiveVersion,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new ProfilePublishedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
ContentHash: contentHash,
|
||||
PreviousActiveVersion: previousActiveVersion);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a profile deprecated event.
|
||||
/// </summary>
|
||||
public async Task PublishProfileDeprecatedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
string? reason,
|
||||
string? successorVersion,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new ProfileDeprecatedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
Reason: reason,
|
||||
SuccessorVersion: successorVersion);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a profile archived event.
|
||||
/// </summary>
|
||||
public async Task PublishProfileArchivedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new ProfileArchivedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a severity threshold changed event.
|
||||
/// </summary>
|
||||
public async Task PublishSeverityThresholdChangedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
IReadOnlyList<ThresholdChange> changes,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new SeverityThresholdChangedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
Changes: changes);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a weight changed event.
|
||||
/// </summary>
|
||||
public async Task PublishWeightChangedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
string signalName,
|
||||
double oldWeight,
|
||||
double newWeight,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new WeightChangedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
SignalName: signalName,
|
||||
OldWeight: oldWeight,
|
||||
NewWeight: newWeight);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a scope attached event.
|
||||
/// </summary>
|
||||
public async Task PublishScopeAttachedAsync(
|
||||
string profileId,
|
||||
string version,
|
||||
string scopeType,
|
||||
string scopeId,
|
||||
string attachmentId,
|
||||
string? actor,
|
||||
string? correlationId = null)
|
||||
{
|
||||
var evt = new ScopeAttachedEvent(
|
||||
EventId: GenerateEventId(),
|
||||
ProfileId: profileId,
|
||||
ProfileVersion: version,
|
||||
Timestamp: _timeProvider.GetUtcNow(),
|
||||
Actor: actor,
|
||||
CorrelationId: correlationId,
|
||||
ScopeType: scopeType,
|
||||
ScopeId: scopeId,
|
||||
AttachmentId: attachmentId);
|
||||
|
||||
await PublishAsync(evt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an event handler.
|
||||
/// </summary>
|
||||
public void RegisterHandler(Func<ProfileEvent, Task> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
lock (_handlersLock)
|
||||
{
|
||||
_eventHandlers.Add(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription for events.
|
||||
/// </summary>
|
||||
public EventSubscription Subscribe(
|
||||
IReadOnlyList<ProfileEventType> eventTypes,
|
||||
string? profileFilter,
|
||||
string? webhookUrl,
|
||||
string? createdBy)
|
||||
{
|
||||
var subscription = new EventSubscription(
|
||||
SubscriptionId: GenerateSubscriptionId(),
|
||||
EventTypes: eventTypes,
|
||||
ProfileFilter: profileFilter,
|
||||
WebhookUrl: webhookUrl,
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
CreatedBy: createdBy);
|
||||
|
||||
_subscriptions[subscription.SubscriptionId] = subscription;
|
||||
_eventQueues[subscription.SubscriptionId] = new ConcurrentQueue<ProfileEvent>();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from events.
|
||||
/// </summary>
|
||||
public bool Unsubscribe(string subscriptionId)
|
||||
{
|
||||
var removed = _subscriptions.TryRemove(subscriptionId, out _);
|
||||
_eventQueues.TryRemove(subscriptionId, out _);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events for a subscription.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProfileEvent> GetEvents(string subscriptionId, int limit = 100)
|
||||
{
|
||||
if (!_eventQueues.TryGetValue(subscriptionId, out var queue))
|
||||
{
|
||||
return Array.Empty<ProfileEvent>();
|
||||
}
|
||||
|
||||
var events = new List<ProfileEvent>();
|
||||
while (events.Count < limit && queue.TryDequeue(out var evt))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
return events.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent events from the global stream.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProfileEvent> GetRecentEvents(int limit = 100)
|
||||
{
|
||||
return _globalEventStream
|
||||
.ToArray()
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events filtered by criteria.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ProfileEvent> GetEventsFiltered(
|
||||
ProfileEventType? eventType,
|
||||
string? profileId,
|
||||
DateTimeOffset? since,
|
||||
int limit = 100)
|
||||
{
|
||||
IEnumerable<ProfileEvent> events = _globalEventStream.ToArray();
|
||||
|
||||
if (eventType.HasValue)
|
||||
{
|
||||
events = events.Where(e => e.EventType == eventType.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(profileId))
|
||||
{
|
||||
events = events.Where(e => e.ProfileId.Equals(profileId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
events = events.Where(e => e.Timestamp >= since.Value);
|
||||
}
|
||||
|
||||
return events
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
private async Task PublishAsync(ProfileEvent evt)
|
||||
{
|
||||
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("profile_event.publish");
|
||||
activity?.SetTag("event.type", evt.EventType.ToString());
|
||||
activity?.SetTag("profile.id", evt.ProfileId);
|
||||
|
||||
// Add to global stream
|
||||
_globalEventStream.Enqueue(evt);
|
||||
|
||||
// Trim global stream if too large
|
||||
while (_globalEventStream.Count > MaxGlobalEvents)
|
||||
{
|
||||
_globalEventStream.TryDequeue(out _);
|
||||
}
|
||||
|
||||
// Distribute to matching subscriptions
|
||||
foreach (var (subscriptionId, subscription) in _subscriptions)
|
||||
{
|
||||
if (MatchesSubscription(evt, subscription))
|
||||
{
|
||||
if (_eventQueues.TryGetValue(subscriptionId, out var queue))
|
||||
{
|
||||
queue.Enqueue(evt);
|
||||
|
||||
// Trim queue if too large
|
||||
while (queue.Count > MaxEventsPerQueue)
|
||||
{
|
||||
queue.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke registered handlers
|
||||
List<Func<ProfileEvent, Task>> handlers;
|
||||
lock (_handlersLock)
|
||||
{
|
||||
handlers = _eventHandlers.ToList();
|
||||
}
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler(evt).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error invoking event handler for {EventType}", evt.EventType);
|
||||
}
|
||||
}
|
||||
|
||||
PolicyEngineTelemetry.ProfileEventsPublished.Add(1);
|
||||
_logger.LogInformation(
|
||||
"Published {EventType} event for profile {ProfileId} v{Version}",
|
||||
evt.EventType, evt.ProfileId, evt.ProfileVersion);
|
||||
}
|
||||
|
||||
private static bool MatchesSubscription(ProfileEvent evt, EventSubscription subscription)
|
||||
{
|
||||
// Check event type filter
|
||||
if (!subscription.EventTypes.Contains(evt.EventType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check profile filter (supports wildcards)
|
||||
if (!string.IsNullOrWhiteSpace(subscription.ProfileFilter))
|
||||
{
|
||||
if (subscription.ProfileFilter.EndsWith("*"))
|
||||
{
|
||||
var prefix = subscription.ProfileFilter[..^1];
|
||||
if (!evt.ProfileId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!evt.ProfileId.Equals(subscription.ProfileFilter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GenerateEventId()
|
||||
{
|
||||
var guid = Guid.NewGuid().ToByteArray();
|
||||
return $"pev-{Convert.ToHexStringLower(guid)[..16]}";
|
||||
}
|
||||
|
||||
private static string GenerateSubscriptionId()
|
||||
{
|
||||
var guid = Guid.NewGuid().ToByteArray();
|
||||
return $"psub-{Convert.ToHexStringLower(guid)[..16]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user