using System; using System.Collections.Generic; using System; using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace StellaOps.Policy; public sealed class PolicyPreviewService { private readonly PolicySnapshotStore _snapshotStore; private readonly ILogger _logger; public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger logger) { _snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default) { if (request is null) { throw new ArgumentNullException(nameof(request)); } var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false); if (snapshot is null) { _logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length); return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray.Empty, 0); } var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings); var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig); var diffs = BuildDiffs(baseline, projected); var changed = diffs.Count(static diff => diff.Changed); _logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed); return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed); } private async Task<(PolicySnapshot? Snapshot, ImmutableArray Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken) { if (request.ProposedPolicy is not null) { var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format); if (!binding.Success) { return (null, binding.Issues); } var digest = PolicyDigest.Compute(binding.Document); var snapshot = new PolicySnapshot( request.SnapshotOverride?.RevisionNumber + 1 ?? 0, request.SnapshotOverride?.RevisionId ?? "preview", digest, DateTimeOffset.UtcNow, request.ProposedPolicy.Actor, request.ProposedPolicy.Format, binding.Document, binding.Issues, PolicyScoringConfig.Default); return (snapshot, binding.Issues); } if (request.SnapshotOverride is not null) { return (request.SnapshotOverride, ImmutableArray.Empty); } var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false); if (latest is not null) { return (latest, ImmutableArray.Empty); } return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$"))); } private static ImmutableArray Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray findings) { if (findings.IsDefaultOrEmpty) { return ImmutableArray.Empty; } var results = ImmutableArray.CreateBuilder(findings.Length); foreach (var finding in findings) { var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding); results.Add(verdict); } return results.ToImmutable(); } private static ImmutableDictionary BuildBaseline(ImmutableArray baseline, ImmutableArray projected, PolicyScoringConfig scoringConfig) { var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); if (!baseline.IsDefaultOrEmpty) { foreach (var verdict in baseline) { if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId)) { builder.Add(verdict.FindingId, verdict); } } } foreach (var verdict in projected) { if (!builder.ContainsKey(verdict.FindingId)) { builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig)); } } return builder.ToImmutable(); } private static ImmutableArray BuildDiffs(ImmutableDictionary baseline, ImmutableArray projected) { var diffs = ImmutableArray.CreateBuilder(projected.Length); foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal)) { var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing) ? existing : new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass); diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict)); } return diffs.ToImmutable(); } }