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
627 lines
24 KiB
C#
627 lines
24 KiB
C#
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Notify.Engine;
|
|
using StellaOps.Notify.Models;
|
|
using StellaOps.Notifier.Worker.Storage;
|
|
using StellaOps.Notifier.Worker.Correlation;
|
|
|
|
namespace StellaOps.Notifier.Worker.Simulation;
|
|
|
|
/// <summary>
|
|
/// Default implementation of the notification simulation engine.
|
|
/// Dry-runs rules against events to preview what actions would be triggered.
|
|
/// </summary>
|
|
public sealed class DefaultNotifySimulationEngine : INotifySimulationEngine
|
|
{
|
|
private readonly INotifyRuleRepository _ruleRepository;
|
|
private readonly INotifyChannelRepository _channelRepository;
|
|
private readonly INotifyAuditRepository _auditRepository;
|
|
private readonly INotifyRuleEvaluator _ruleEvaluator;
|
|
private readonly INotifyThrottler? _throttler;
|
|
private readonly IQuietHoursEvaluator? _quietHoursEvaluator;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<DefaultNotifySimulationEngine> _logger;
|
|
|
|
private static readonly TimeSpan DefaultThrottleWindow = TimeSpan.FromMinutes(5);
|
|
|
|
public DefaultNotifySimulationEngine(
|
|
INotifyRuleRepository ruleRepository,
|
|
INotifyChannelRepository channelRepository,
|
|
INotifyAuditRepository auditRepository,
|
|
INotifyRuleEvaluator ruleEvaluator,
|
|
INotifyThrottler? throttler,
|
|
IQuietHoursEvaluator? quietHoursEvaluator,
|
|
TimeProvider timeProvider,
|
|
ILogger<DefaultNotifySimulationEngine> logger)
|
|
{
|
|
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
|
|
_channelRepository = channelRepository ?? throw new ArgumentNullException(nameof(channelRepository));
|
|
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
|
_ruleEvaluator = ruleEvaluator ?? throw new ArgumentNullException(nameof(ruleEvaluator));
|
|
_throttler = throttler;
|
|
_quietHoursEvaluator = quietHoursEvaluator;
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async Task<NotifySimulationResult> SimulateAsync(
|
|
NotifySimulationRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var simulationId = Guid.NewGuid().ToString("N");
|
|
var evaluationTime = request.EvaluationTimestamp ?? _timeProvider.GetUtcNow();
|
|
|
|
_logger.LogInformation(
|
|
"Starting simulation {SimulationId} for tenant {TenantId}: period {PeriodStart} to {PeriodEnd}",
|
|
simulationId, request.TenantId, request.PeriodStart, request.PeriodEnd);
|
|
|
|
// Load rules
|
|
var allRules = await _ruleRepository.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
|
var rules = FilterRules(allRules, request.RuleIds);
|
|
|
|
_logger.LogDebug(
|
|
"Simulation {SimulationId}: loaded {RuleCount} rules ({FilteredCount} after filtering)",
|
|
simulationId, allRules.Count, rules.Count);
|
|
|
|
// Load historical events from audit log
|
|
var auditEntries = await _auditRepository.QueryAsync(
|
|
request.TenantId,
|
|
request.PeriodStart,
|
|
request.MaxEvents,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
// Convert audit entries to events for simulation
|
|
var events = ConvertAuditEntriesToEvents(auditEntries, request.PeriodStart, request.PeriodEnd, request.EventKinds);
|
|
|
|
_logger.LogDebug(
|
|
"Simulation {SimulationId}: loaded {EventCount} events from audit log",
|
|
simulationId, events.Count);
|
|
|
|
// Load channels for action evaluation
|
|
var channels = await LoadChannelsAsync(request.TenantId, rules, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Run simulation
|
|
var eventResults = new List<SimulatedEventResult>();
|
|
var ruleSummaries = new Dictionary<string, RuleSummaryBuilder>(StringComparer.Ordinal);
|
|
|
|
foreach (var rule in rules)
|
|
{
|
|
ruleSummaries[rule.RuleId] = new RuleSummaryBuilder(rule);
|
|
}
|
|
|
|
foreach (var @event in events)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var eventResult = await SimulateEventAsync(
|
|
@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken).ConfigureAwait(false);
|
|
eventResults.Add(eventResult);
|
|
}
|
|
|
|
stopwatch.Stop();
|
|
|
|
var result = new NotifySimulationResult
|
|
{
|
|
SimulationId = simulationId,
|
|
TenantId = request.TenantId,
|
|
SimulatedAt = _timeProvider.GetUtcNow(),
|
|
EventsEvaluated = events.Count,
|
|
RulesEvaluated = rules.Count,
|
|
TotalMatches = eventResults.Sum(e => e.MatchedRules),
|
|
TotalActions = eventResults.Sum(e => e.TriggeredActions),
|
|
EventResults = eventResults.ToImmutableArray(),
|
|
RuleSummaries = ruleSummaries.Values
|
|
.Select(b => b.Build())
|
|
.OrderByDescending(s => s.MatchCount)
|
|
.ToImmutableArray(),
|
|
Duration = stopwatch.Elapsed
|
|
};
|
|
|
|
_logger.LogInformation(
|
|
"Completed simulation {SimulationId}: {EventsEvaluated} events, {TotalMatches} matches, {TotalActions} actions in {Duration}ms",
|
|
simulationId, result.EventsEvaluated, result.TotalMatches, result.TotalActions, result.Duration.TotalMilliseconds);
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<SimulatedEventResult> SimulateSingleEventAsync(
|
|
string tenantId,
|
|
JsonObject eventPayload,
|
|
IEnumerable<string>? ruleIds = null,
|
|
DateTimeOffset? evaluationTimestamp = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
|
ArgumentNullException.ThrowIfNull(eventPayload);
|
|
|
|
var evaluationTime = evaluationTimestamp ?? _timeProvider.GetUtcNow();
|
|
|
|
// Parse event from payload
|
|
var @event = ParseEventFromPayload(tenantId, eventPayload);
|
|
|
|
// Load rules
|
|
var allRules = await _ruleRepository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
|
var rules = FilterRules(allRules, ruleIds?.ToImmutableArray() ?? []);
|
|
|
|
// Load channels
|
|
var channels = await LoadChannelsAsync(tenantId, rules, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Create dummy request for simulation
|
|
var request = new NotifySimulationRequest
|
|
{
|
|
TenantId = tenantId,
|
|
PeriodStart = evaluationTime.AddHours(-1),
|
|
PeriodEnd = evaluationTime,
|
|
EvaluationTimestamp = evaluationTime,
|
|
EvaluateThrottling = true,
|
|
EvaluateQuietHours = true,
|
|
IncludeNonMatches = true
|
|
};
|
|
|
|
var ruleSummaries = new Dictionary<string, RuleSummaryBuilder>(StringComparer.Ordinal);
|
|
return await SimulateEventAsync(@event, rules, channels, request, evaluationTime, ruleSummaries, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<SimulatedEventResult> SimulateEventAsync(
|
|
NotifyEvent @event,
|
|
IReadOnlyList<NotifyRule> rules,
|
|
IReadOnlyDictionary<string, NotifyChannel> channels,
|
|
NotifySimulationRequest request,
|
|
DateTimeOffset evaluationTime,
|
|
Dictionary<string, RuleSummaryBuilder> ruleSummaries,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var matches = new List<SimulatedRuleMatch>();
|
|
var nonMatches = new List<SimulatedRuleNonMatch>();
|
|
|
|
foreach (var rule in rules)
|
|
{
|
|
var outcome = _ruleEvaluator.Evaluate(rule, @event, evaluationTime);
|
|
|
|
if (outcome.IsMatch)
|
|
{
|
|
var actionResults = await EvaluateActionsAsync(
|
|
@event, rule, outcome.Actions, channels, request, evaluationTime, cancellationToken).ConfigureAwait(false);
|
|
|
|
var explanations = BuildMatchExplanations(rule, @event);
|
|
|
|
matches.Add(new SimulatedRuleMatch
|
|
{
|
|
RuleId = rule.RuleId,
|
|
RuleName = rule.Name ?? rule.RuleId,
|
|
Priority = 0, // NotifyRule doesn't have priority, default to 0
|
|
MatchedAt = outcome.MatchedAt ?? evaluationTime,
|
|
Actions = actionResults,
|
|
MatchExplanations = explanations
|
|
});
|
|
|
|
if (ruleSummaries.TryGetValue(rule.RuleId, out var summary))
|
|
{
|
|
summary.RecordMatch(actionResults.Length);
|
|
}
|
|
}
|
|
else if (request.IncludeNonMatches)
|
|
{
|
|
var explanation = BuildNonMatchExplanation(outcome.Reason ?? "unknown", rule, @event);
|
|
|
|
nonMatches.Add(new SimulatedRuleNonMatch
|
|
{
|
|
RuleId = rule.RuleId,
|
|
RuleName = rule.Name ?? rule.RuleId,
|
|
Reason = outcome.Reason ?? "unknown",
|
|
Explanation = explanation
|
|
});
|
|
|
|
if (ruleSummaries.TryGetValue(rule.RuleId, out var summary))
|
|
{
|
|
summary.RecordNonMatch(outcome.Reason ?? "unknown");
|
|
}
|
|
}
|
|
}
|
|
|
|
return new SimulatedEventResult
|
|
{
|
|
EventId = @event.EventId,
|
|
Kind = @event.Kind,
|
|
EventTimestamp = @event.Ts,
|
|
MatchedRules = matches.Count,
|
|
TriggeredActions = matches.Sum(m => m.Actions.Count(a => a.WouldDeliver)),
|
|
Matches = matches.OrderBy(m => m.Priority).ToImmutableArray(),
|
|
NonMatches = nonMatches.ToImmutableArray()
|
|
};
|
|
}
|
|
|
|
private async Task<ImmutableArray<SimulatedActionResult>> EvaluateActionsAsync(
|
|
NotifyEvent @event,
|
|
NotifyRule rule,
|
|
ImmutableArray<NotifyRuleAction> actions,
|
|
IReadOnlyDictionary<string, NotifyChannel> channels,
|
|
NotifySimulationRequest request,
|
|
DateTimeOffset evaluationTime,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<SimulatedActionResult>();
|
|
|
|
foreach (var action in actions)
|
|
{
|
|
if (!action.Enabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var channelId = action.Channel?.Trim() ?? string.Empty;
|
|
channels.TryGetValue(channelId, out var channel);
|
|
|
|
var wouldDeliver = true;
|
|
var deliveryExplanation = "Would be delivered successfully";
|
|
string? throttleReason = null;
|
|
string? quietHoursReason = null;
|
|
string? channelBlockReason = null;
|
|
|
|
// Check channel availability
|
|
if (channel is null)
|
|
{
|
|
wouldDeliver = false;
|
|
channelBlockReason = $"Channel '{channelId}' not found";
|
|
deliveryExplanation = channelBlockReason;
|
|
}
|
|
else if (!channel.Enabled)
|
|
{
|
|
wouldDeliver = false;
|
|
channelBlockReason = $"Channel '{channelId}' is disabled";
|
|
deliveryExplanation = channelBlockReason;
|
|
}
|
|
|
|
// Check throttling
|
|
if (wouldDeliver && request.EvaluateThrottling && _throttler is not null)
|
|
{
|
|
var throttleKey = $"{rule.RuleId}:{action.ActionId}:{@event.Kind}";
|
|
var throttleWindow = action.Throttle is { Ticks: > 0 } ? action.Throttle.Value : DefaultThrottleWindow;
|
|
var throttleResult = await _throttler.CheckAsync(
|
|
@event.Tenant,
|
|
throttleKey,
|
|
throttleWindow,
|
|
null,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (throttleResult.IsThrottled)
|
|
{
|
|
wouldDeliver = false;
|
|
throttleReason = $"Would be throttled (key: {throttleKey})";
|
|
deliveryExplanation = throttleReason;
|
|
}
|
|
}
|
|
|
|
// Check quiet hours
|
|
if (wouldDeliver && request.EvaluateQuietHours && _quietHoursEvaluator is not null)
|
|
{
|
|
var quietHoursResult = await _quietHoursEvaluator.EvaluateAsync(
|
|
@event.Tenant, @event.Kind, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (quietHoursResult.IsSuppressed)
|
|
{
|
|
wouldDeliver = false;
|
|
quietHoursReason = quietHoursResult.Reason ?? "In quiet hours period";
|
|
deliveryExplanation = quietHoursReason;
|
|
}
|
|
}
|
|
|
|
if (wouldDeliver)
|
|
{
|
|
deliveryExplanation = $"Would deliver to {channel?.Type.ToString() ?? "unknown"} channel '{channelId}'";
|
|
if (!string.IsNullOrWhiteSpace(action.Template))
|
|
{
|
|
deliveryExplanation += $" using template '{action.Template}'";
|
|
}
|
|
}
|
|
|
|
results.Add(new SimulatedActionResult
|
|
{
|
|
ActionId = action.ActionId,
|
|
ChannelId = channelId,
|
|
ChannelType = channel?.Type ?? NotifyChannelType.Custom,
|
|
TemplateId = action.Template,
|
|
WouldDeliver = wouldDeliver,
|
|
DeliveryExplanation = deliveryExplanation,
|
|
ThrottleReason = throttleReason,
|
|
QuietHoursReason = quietHoursReason,
|
|
ChannelBlockReason = channelBlockReason
|
|
});
|
|
}
|
|
|
|
return results.ToImmutableArray();
|
|
}
|
|
|
|
private static ImmutableArray<string> BuildMatchExplanations(NotifyRule rule, NotifyEvent @event)
|
|
{
|
|
var explanations = new List<string>();
|
|
var match = rule.Match;
|
|
|
|
if (!match.EventKinds.IsDefaultOrEmpty)
|
|
{
|
|
explanations.Add($"Event kind '{@event.Kind}' matched filter [{string.Join(", ", match.EventKinds)}]");
|
|
}
|
|
else
|
|
{
|
|
explanations.Add("Event kind matched (no filter specified)");
|
|
}
|
|
|
|
if (!match.Namespaces.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Namespace))
|
|
{
|
|
explanations.Add($"Namespace '{@event.Scope.Namespace}' matched filter");
|
|
}
|
|
|
|
if (!match.Repositories.IsDefaultOrEmpty && !string.IsNullOrWhiteSpace(@event.Scope?.Repo))
|
|
{
|
|
explanations.Add($"Repository '{@event.Scope.Repo}' matched filter");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(match.MinSeverity))
|
|
{
|
|
explanations.Add($"Severity met minimum threshold of '{match.MinSeverity}'");
|
|
}
|
|
|
|
if (!match.Labels.IsDefaultOrEmpty)
|
|
{
|
|
explanations.Add($"Labels matched required set: [{string.Join(", ", match.Labels)}]");
|
|
}
|
|
|
|
return explanations.ToImmutableArray();
|
|
}
|
|
|
|
private static string BuildNonMatchExplanation(string reason, NotifyRule rule, NotifyEvent @event)
|
|
{
|
|
return reason switch
|
|
{
|
|
"rule_disabled" => $"Rule '{rule.Name ?? rule.RuleId}' is disabled",
|
|
"event_kind_mismatch" => $"Event kind '{@event.Kind}' not in rule filter [{string.Join(", ", rule.Match.EventKinds)}]",
|
|
"namespace_mismatch" => $"Namespace '{@event.Scope?.Namespace ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Namespaces)}]",
|
|
"repository_mismatch" => $"Repository '{@event.Scope?.Repo ?? "(none)"}' not in rule filter [{string.Join(", ", rule.Match.Repositories)}]",
|
|
"digest_mismatch" => $"Digest '{@event.Scope?.Digest ?? "(none)"}' not in rule filter",
|
|
"component_mismatch" => "Event component PURLs did not match rule filter",
|
|
"kev_required" => "Rule requires KEV label but event does not have it",
|
|
"label_mismatch" => $"Event labels did not match required set [{string.Join(", ", rule.Match.Labels)}]",
|
|
"severity_below_threshold" => $"Event severity below minimum '{rule.Match.MinSeverity}'",
|
|
"verdict_mismatch" => $"Event verdict not in rule filter [{string.Join(", ", rule.Match.Verdicts)}]",
|
|
"no_enabled_actions" => "Rule has no enabled actions",
|
|
_ => $"Rule did not match: {reason}"
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<NotifyRule> FilterRules(
|
|
IReadOnlyList<NotifyRule> rules,
|
|
ImmutableArray<string> ruleIds)
|
|
{
|
|
if (ruleIds.IsDefaultOrEmpty)
|
|
{
|
|
return rules.Where(r => r.Enabled).ToList();
|
|
}
|
|
|
|
var ruleIdSet = ruleIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
return rules.Where(r => ruleIdSet.Contains(r.RuleId)).ToList();
|
|
}
|
|
|
|
private async Task<IReadOnlyDictionary<string, NotifyChannel>> LoadChannelsAsync(
|
|
string tenantId,
|
|
IReadOnlyList<NotifyRule> rules,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var channelIds = rules
|
|
.SelectMany(r => r.Actions)
|
|
.Where(a => !string.IsNullOrWhiteSpace(a.Channel))
|
|
.Select(a => a.Channel!.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
var channels = new Dictionary<string, NotifyChannel>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var channelId in channelIds)
|
|
{
|
|
var channel = await _channelRepository.GetAsync(tenantId, channelId, cancellationToken).ConfigureAwait(false);
|
|
if (channel is not null)
|
|
{
|
|
channels[channelId] = channel;
|
|
}
|
|
}
|
|
|
|
return channels;
|
|
}
|
|
|
|
private static IReadOnlyList<NotifyEvent> ConvertAuditEntriesToEvents(
|
|
IReadOnlyList<NotifyAuditEntry> auditEntries,
|
|
DateTimeOffset periodStart,
|
|
DateTimeOffset periodEnd,
|
|
ImmutableArray<string> eventKinds)
|
|
{
|
|
var kindSet = eventKinds.IsDefaultOrEmpty
|
|
? null
|
|
: eventKinds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var events = new List<NotifyEvent>();
|
|
|
|
foreach (var entry in auditEntries)
|
|
{
|
|
if (entry.Timestamp < periodStart || entry.Timestamp >= periodEnd)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var eventKind = ExtractEventKindFromAuditEntry(entry);
|
|
if (string.IsNullOrWhiteSpace(eventKind))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (kindSet is not null && !kindSet.Contains(eventKind))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var eventId = ExtractEventIdFromAuditEntry(entry);
|
|
var payload = ToPayload(entry.Data);
|
|
|
|
var @event = NotifyEvent.Create(
|
|
eventId: eventId,
|
|
kind: eventKind,
|
|
tenant: entry.TenantId,
|
|
ts: entry.Timestamp,
|
|
payload: payload);
|
|
|
|
events.Add(@event);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
private static string? ExtractEventKindFromAuditEntry(NotifyAuditEntry entry)
|
|
{
|
|
// The event kind might be encoded in the action field or payload
|
|
// Action format is typically "event.kind.action" or we look in payload
|
|
var action = entry.Action;
|
|
|
|
// Try to extract from action (e.g., "pack.approval.ingested" -> "pack.approval")
|
|
if (!string.IsNullOrWhiteSpace(action))
|
|
{
|
|
var parts = action.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length >= 2)
|
|
{
|
|
return string.Join(".", parts.Take(parts.Length - 1));
|
|
}
|
|
}
|
|
|
|
// Try to extract from payload
|
|
if (entry.Data.TryGetValue("Kind", out var kind) ||
|
|
entry.Data.TryGetValue("kind", out kind))
|
|
{
|
|
return kind;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static Guid ExtractEventIdFromAuditEntry(NotifyAuditEntry entry)
|
|
{
|
|
if (entry.Data.TryGetValue("eventId", out var eventId) &&
|
|
Guid.TryParse(eventId, out var parsed))
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return Guid.NewGuid();
|
|
}
|
|
|
|
private static JsonNode ToPayload(IReadOnlyDictionary<string, string> data)
|
|
{
|
|
var obj = new JsonObject();
|
|
foreach (var (key, value) in data)
|
|
{
|
|
obj[key] = value;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
private static NotifyEvent ParseEventFromPayload(string tenantId, JsonObject payload)
|
|
{
|
|
var eventId = payload.TryGetPropertyValue("eventId", out var idNode) && idNode is JsonValue idValue
|
|
? (Guid.TryParse(idValue.ToString(), out var id) ? id : Guid.NewGuid())
|
|
: Guid.NewGuid();
|
|
|
|
var kind = payload.TryGetPropertyValue("kind", out var kindNode) && kindNode is JsonValue kindValue
|
|
? kindValue.ToString()
|
|
: "simulation.test";
|
|
|
|
var ts = payload.TryGetPropertyValue("ts", out var tsNode) && tsNode is JsonValue tsValue
|
|
&& DateTimeOffset.TryParse(tsValue.ToString(), out var timestamp)
|
|
? timestamp
|
|
: DateTimeOffset.UtcNow;
|
|
|
|
var eventPayload = payload.TryGetPropertyValue("payload", out var payloadNode)
|
|
? payloadNode
|
|
: payload;
|
|
|
|
NotifyEventScope? scope = null;
|
|
if (payload.TryGetPropertyValue("scope", out var scopeNode) && scopeNode is JsonObject scopeObj)
|
|
{
|
|
scope = NotifyEventScope.Create(
|
|
@namespace: GetStringProperty(scopeObj, "namespace"),
|
|
repo: GetStringProperty(scopeObj, "repo"),
|
|
digest: GetStringProperty(scopeObj, "digest"),
|
|
component: GetStringProperty(scopeObj, "component"),
|
|
image: GetStringProperty(scopeObj, "image"));
|
|
}
|
|
|
|
var attributes = ImmutableDictionary<string, string>.Empty;
|
|
if (payload.TryGetPropertyValue("attributes", out var attrNode) && attrNode is JsonObject attrObj)
|
|
{
|
|
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
|
foreach (var prop in attrObj)
|
|
{
|
|
if (prop.Value is JsonValue value)
|
|
{
|
|
builder[prop.Key] = value.ToString();
|
|
}
|
|
}
|
|
attributes = builder.ToImmutable();
|
|
}
|
|
|
|
return NotifyEvent.Create(
|
|
eventId: eventId,
|
|
kind: kind,
|
|
tenant: tenantId,
|
|
ts: ts,
|
|
payload: eventPayload,
|
|
scope: scope,
|
|
attributes: attributes);
|
|
}
|
|
|
|
private static string? GetStringProperty(JsonObject obj, string name)
|
|
{
|
|
return obj.TryGetPropertyValue(name, out var node) && node is JsonValue value
|
|
? value.ToString()
|
|
: null;
|
|
}
|
|
|
|
private sealed class RuleSummaryBuilder
|
|
{
|
|
private readonly NotifyRule _rule;
|
|
private int _matchCount;
|
|
private int _actionCount;
|
|
private readonly Dictionary<string, int> _nonMatchReasons = new(StringComparer.Ordinal);
|
|
|
|
public RuleSummaryBuilder(NotifyRule rule)
|
|
{
|
|
_rule = rule;
|
|
}
|
|
|
|
public void RecordMatch(int actions)
|
|
{
|
|
_matchCount++;
|
|
_actionCount += actions;
|
|
}
|
|
|
|
public void RecordNonMatch(string reason)
|
|
{
|
|
_nonMatchReasons.TryGetValue(reason, out var count);
|
|
_nonMatchReasons[reason] = count + 1;
|
|
}
|
|
|
|
public SimulatedRuleSummary Build()
|
|
{
|
|
return new SimulatedRuleSummary
|
|
{
|
|
RuleId = _rule.RuleId,
|
|
RuleName = _rule.Name ?? _rule.RuleId,
|
|
MatchCount = _matchCount,
|
|
ActionCount = _actionCount,
|
|
NonMatchReasons = _nonMatchReasons.ToImmutableDictionary()
|
|
};
|
|
}
|
|
}
|
|
}
|