up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

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

View File

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