using System.Collections.Immutable; using System.Text.Json.Serialization; namespace StellaOps.Scheduler.Models; /// /// Execution record for a scheduler run. /// public sealed record Run { public Run( string id, string tenantId, RunTrigger trigger, RunState state, RunStats stats, DateTimeOffset createdAt, RunReason? reason = null, string? scheduleId = null, DateTimeOffset? startedAt = null, DateTimeOffset? finishedAt = null, string? error = null, IEnumerable? deltas = null, string? retryOf = null, string? schemaVersion = null) : this( id, tenantId, trigger, state, stats, reason ?? RunReason.Empty, scheduleId, Validation.NormalizeTimestamp(createdAt), Validation.NormalizeTimestamp(startedAt), Validation.NormalizeTimestamp(finishedAt), Validation.TrimToNull(error), NormalizeDeltas(deltas), Validation.TrimToNull(retryOf), schemaVersion) { } [JsonConstructor] public Run( string id, string tenantId, RunTrigger trigger, RunState state, RunStats stats, RunReason reason, string? scheduleId, DateTimeOffset createdAt, DateTimeOffset? startedAt, DateTimeOffset? finishedAt, string? error, ImmutableArray deltas, string? retryOf, string? schemaVersion = null) { Id = Validation.EnsureId(id, nameof(id)); TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId)); Trigger = trigger; State = state; Stats = stats ?? throw new ArgumentNullException(nameof(stats)); Reason = reason ?? RunReason.Empty; ScheduleId = Validation.TrimToNull(scheduleId); CreatedAt = Validation.NormalizeTimestamp(createdAt); StartedAt = Validation.NormalizeTimestamp(startedAt); FinishedAt = Validation.NormalizeTimestamp(finishedAt); Error = Validation.TrimToNull(error); Deltas = deltas.IsDefault ? ImmutableArray.Empty : deltas.OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal).ToImmutableArray(); RetryOf = Validation.TrimToNull(retryOf); SchemaVersion = SchedulerSchemaVersions.EnsureRun(schemaVersion); } public string SchemaVersion { get; } public string Id { get; } public string TenantId { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ScheduleId { get; } public RunTrigger Trigger { get; } public RunState State { get; init; } public RunStats Stats { get; init; } public RunReason Reason { get; } public DateTimeOffset CreatedAt { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? StartedAt { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? FinishedAt { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Error { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableArray Deltas { get; } = ImmutableArray.Empty; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? RetryOf { get; } private static ImmutableArray NormalizeDeltas(IEnumerable? deltas) { if (deltas is null) { return ImmutableArray.Empty; } return deltas .Where(static delta => delta is not null) .Select(static delta => delta!) .OrderBy(static delta => delta.ImageDigest, StringComparer.Ordinal) .ToImmutableArray(); } } /// /// Context describing why a run executed. /// public sealed record RunReason { public static RunReason Empty { get; } = new(); public RunReason( string? manualReason = null, string? conselierExportId = null, string? excitorExportId = null, string? cursor = null) { ManualReason = Validation.TrimToNull(manualReason); ConselierExportId = Validation.TrimToNull(conselierExportId); ExcitorExportId = Validation.TrimToNull(excitorExportId); Cursor = Validation.TrimToNull(cursor); } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ManualReason { get; } = null; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ConselierExportId { get; } = null; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ExcitorExportId { get; } = null; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Cursor { get; } = null; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ImpactWindowFrom { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ImpactWindowTo { get; init; } } /// /// Aggregated counters for a scheduler run. /// public sealed record RunStats { public static RunStats Empty { get; } = new(); public RunStats( int candidates = 0, int deduped = 0, int queued = 0, int completed = 0, int deltas = 0, int newCriticals = 0, int newHigh = 0, int newMedium = 0, int newLow = 0) { Candidates = Validation.EnsureNonNegative(candidates, nameof(candidates)); Deduped = Validation.EnsureNonNegative(deduped, nameof(deduped)); Queued = Validation.EnsureNonNegative(queued, nameof(queued)); Completed = Validation.EnsureNonNegative(completed, nameof(completed)); Deltas = Validation.EnsureNonNegative(deltas, nameof(deltas)); NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)); NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh)); NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium)); NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow)); } public int Candidates { get; } = 0; public int Deduped { get; } = 0; public int Queued { get; } = 0; public int Completed { get; } = 0; public int Deltas { get; } = 0; public int NewCriticals { get; } = 0; public int NewHigh { get; } = 0; public int NewMedium { get; } = 0; public int NewLow { get; } = 0; } /// /// Snapshot of delta impact for an image processed in a run. /// public sealed record DeltaSummary { public DeltaSummary( string imageDigest, int newFindings, int newCriticals, int newHigh, int newMedium, int newLow, IEnumerable? kevHits = null, IEnumerable? topFindings = null, string? reportUrl = null, DeltaAttestation? attestation = null, DateTimeOffset? detectedAt = null) : this( imageDigest, Validation.EnsureNonNegative(newFindings, nameof(newFindings)), Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)), Validation.EnsureNonNegative(newHigh, nameof(newHigh)), Validation.EnsureNonNegative(newMedium, nameof(newMedium)), Validation.EnsureNonNegative(newLow, nameof(newLow)), NormalizeKevHits(kevHits), NormalizeFindings(topFindings), Validation.TrimToNull(reportUrl), attestation, Validation.NormalizeTimestamp(detectedAt)) { } [JsonConstructor] public DeltaSummary( string imageDigest, int newFindings, int newCriticals, int newHigh, int newMedium, int newLow, ImmutableArray kevHits, ImmutableArray topFindings, string? reportUrl, DeltaAttestation? attestation, DateTimeOffset? detectedAt) { ImageDigest = Validation.EnsureDigestFormat(imageDigest, nameof(imageDigest)); NewFindings = Validation.EnsureNonNegative(newFindings, nameof(newFindings)); NewCriticals = Validation.EnsureNonNegative(newCriticals, nameof(newCriticals)); NewHigh = Validation.EnsureNonNegative(newHigh, nameof(newHigh)); NewMedium = Validation.EnsureNonNegative(newMedium, nameof(newMedium)); NewLow = Validation.EnsureNonNegative(newLow, nameof(newLow)); KevHits = kevHits.IsDefault ? ImmutableArray.Empty : kevHits; TopFindings = topFindings.IsDefault ? ImmutableArray.Empty : topFindings .OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance) .ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal) .ToImmutableArray(); ReportUrl = Validation.TrimToNull(reportUrl); Attestation = attestation; DetectedAt = Validation.NormalizeTimestamp(detectedAt); } public string ImageDigest { get; } public int NewFindings { get; } public int NewCriticals { get; } public int NewHigh { get; } public int NewMedium { get; } public int NewLow { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableArray KevHits { get; } = ImmutableArray.Empty; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableArray TopFindings { get; } = ImmutableArray.Empty; [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ReportUrl { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DeltaAttestation? Attestation { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? DetectedAt { get; } private static ImmutableArray NormalizeKevHits(IEnumerable? kevHits) => Validation.NormalizeStringSet(kevHits, nameof(kevHits)); private static ImmutableArray NormalizeFindings(IEnumerable? findings) { if (findings is null) { return ImmutableArray.Empty; } return findings .Where(static finding => finding is not null) .Select(static finding => finding!) .OrderBy(static finding => finding.Severity, SeverityRankComparer.Instance) .ThenBy(static finding => finding.VulnerabilityId, StringComparer.Ordinal) .ToImmutableArray(); } } /// /// Top finding entry included in delta summaries. /// public sealed record DeltaFinding { public DeltaFinding(string purl, string vulnerabilityId, SeverityRank severity, string? link = null) { Purl = Validation.EnsureSimpleIdentifier(purl, nameof(purl)); VulnerabilityId = Validation.EnsureSimpleIdentifier(vulnerabilityId, nameof(vulnerabilityId)); Severity = severity; Link = Validation.TrimToNull(link); } public string Purl { get; } public string VulnerabilityId { get; } public SeverityRank Severity { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Link { get; } } /// /// Rekor/attestation information surfaced with a delta summary. /// public sealed record DeltaAttestation { public DeltaAttestation(string? uuid, bool? verified = null) { Uuid = Validation.TrimToNull(uuid); Verified = verified; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Uuid { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Verified { get; } } internal sealed class SeverityRankComparer : IComparer { public static SeverityRankComparer Instance { get; } = new(); private static readonly Dictionary Order = new() { [SeverityRank.Critical] = 0, [SeverityRank.High] = 1, [SeverityRank.Unknown] = 2, [SeverityRank.Medium] = 3, [SeverityRank.Low] = 4, [SeverityRank.Info] = 5, [SeverityRank.None] = 6, }; public int Compare(SeverityRank x, SeverityRank y) => GetOrder(x).CompareTo(GetOrder(y)); private static int GetOrder(SeverityRank severity) => Order.TryGetValue(severity, out var value) ? value : int.MaxValue; }