save checkpoint: save features
This commit is contained in:
61
src/Unknowns/StellaOps.Unknowns.Services/SlaCalculator.cs
Normal file
61
src/Unknowns/StellaOps.Unknowns.Services/SlaCalculator.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
|
||||
namespace StellaOps.Unknowns.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared SLA band and timing calculations used by monitor and health-check services.
|
||||
/// </summary>
|
||||
public static class SlaCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a queue score to SLA band.
|
||||
/// </summary>
|
||||
public static UnknownsBand GetBand(double score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 0.70 => UnknownsBand.Hot,
|
||||
>= 0.40 => UnknownsBand.Warm,
|
||||
_ => UnknownsBand.Cold
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns configured SLA duration for a band.
|
||||
/// </summary>
|
||||
public static TimeSpan GetSlaLimit(UnknownsBand band, UnknownsSlaOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return band switch
|
||||
{
|
||||
UnknownsBand.Hot => options.HotSla,
|
||||
UnknownsBand.Warm => options.WarmSla,
|
||||
_ => options.ColdSla
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes remaining SLA time for a queue entry.
|
||||
/// </summary>
|
||||
public static TimeSpan CalculateRemaining(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
|
||||
{
|
||||
var band = GetBand(entry.Score);
|
||||
var slaLimit = GetSlaLimit(band, options);
|
||||
return slaLimit - (now - entry.CreatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes elapsed SLA percentage for a queue entry.
|
||||
/// </summary>
|
||||
public static double CalculatePercentElapsed(GreyQueueEntry entry, DateTimeOffset now, UnknownsSlaOptions options)
|
||||
{
|
||||
var band = GetBand(entry.Score);
|
||||
var slaLimit = GetSlaLimit(band, options);
|
||||
if (slaLimit <= TimeSpan.Zero)
|
||||
{
|
||||
return double.PositiveInfinity;
|
||||
}
|
||||
|
||||
return (now - entry.CreatedAt) / slaLimit;
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,10 @@
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Unknowns.Persistence\StellaOps.Unknowns.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- TODO: Re-enable after aligning service APIs with IGreyQueueRepository -->
|
||||
<!-- Keep legacy watchdog/lifecycle services excluded until their repository API alignment is completed. -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="GreyQueueWatchdogService.cs" />
|
||||
<Compile Remove="UnknownsLifecycleService.cs" />
|
||||
<Compile Remove="UnknownsMetricsService.cs" />
|
||||
<Compile Remove="UnknownsSlaHealthCheck.cs" />
|
||||
<Compile Remove="UnknownsSlaMonitorService.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -96,7 +96,7 @@ public sealed class UnknownsSlaMonitorService : BackgroundService
|
||||
await _notificationPublisher.PublishAsync(new SlaWarningNotification
|
||||
{
|
||||
EntryId = entry.Id,
|
||||
BomRef = entry.BomRef,
|
||||
BomRef = entry.BomRef ?? string.Empty,
|
||||
Band = band,
|
||||
PercentElapsed = percentElapsed * 100,
|
||||
RemainingTime = remaining
|
||||
@@ -110,7 +110,7 @@ public sealed class UnknownsSlaMonitorService : BackgroundService
|
||||
await _notificationPublisher.PublishAsync(new SlaBreachNotification
|
||||
{
|
||||
EntryId = entry.Id,
|
||||
BomRef = entry.BomRef,
|
||||
BomRef = entry.BomRef ?? string.Empty,
|
||||
Band = band,
|
||||
OverdueBy = elapsed - slaLimit
|
||||
}, ct);
|
||||
@@ -170,21 +170,6 @@ public sealed record UnknownsSlaOptions
|
||||
public double WarningThreshold { get; init; } = 0.80;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unknowns band classification.
|
||||
/// </summary>
|
||||
public enum UnknownsBand
|
||||
{
|
||||
/// <summary>High priority (score >= 0.70).</summary>
|
||||
Hot,
|
||||
|
||||
/// <summary>Medium priority (score 0.40-0.69).</summary>
|
||||
Warm,
|
||||
|
||||
/// <summary>Low priority (score < 0.40).</summary>
|
||||
Cold
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SLA warning notification.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Services;
|
||||
|
||||
public sealed class UnknownProofEmitterTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmitProof_WithContainmentDeduction_EmitsContainmentAndFinalScoreNodes()
|
||||
{
|
||||
var unknown = CreateUnknown();
|
||||
var blast = new BlastRadius(120, true, "root");
|
||||
var pressure = new ExploitPressure(0.82, true);
|
||||
var containment = ContainmentSignals.WellSandboxed; // non-zero deduction
|
||||
var ranker = new UnknownRanker();
|
||||
var finalScore = ranker.Rank(blast, unknown.UncertaintyScore, pressure, containment);
|
||||
var emitter = new UnknownProofEmitter();
|
||||
|
||||
var proof = emitter.EmitProof(unknown, blast, pressure, containment, finalScore);
|
||||
|
||||
proof.Count.Should().Be(6);
|
||||
proof.Nodes[0].RuleId.Should().Be("unknown.input");
|
||||
proof.Nodes[0].Kind.Should().Be(ProofNodeKind.Input);
|
||||
proof.Nodes[4].RuleId.Should().Be("unknown.containment");
|
||||
proof.Nodes[4].Delta.Should().BeLessThan(0);
|
||||
proof.Nodes[5].RuleId.Should().Be("unknown.final_score");
|
||||
proof.Nodes[5].Kind.Should().Be(ProofNodeKind.Score);
|
||||
proof.Nodes[5].Total.Should().Be(finalScore);
|
||||
proof.VerifyIntegrity().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitProof_WithoutContainmentDeduction_SkipsContainmentNode()
|
||||
{
|
||||
var unknown = CreateUnknown();
|
||||
var blast = new BlastRadius(10, false, "none");
|
||||
var pressure = new ExploitPressure(0.1, false);
|
||||
var containment = new ContainmentSignals("disabled", "rw", "disabled", 0); // zero deduction
|
||||
var ranker = new UnknownRanker();
|
||||
var finalScore = ranker.Rank(blast, unknown.UncertaintyScore, pressure, containment);
|
||||
var emitter = new UnknownProofEmitter();
|
||||
|
||||
var proof = emitter.EmitProof(unknown, blast, pressure, containment, finalScore);
|
||||
|
||||
proof.Count.Should().Be(5);
|
||||
proof.Nodes.Select(n => n.RuleId).Should().NotContain("unknown.containment");
|
||||
proof.Nodes[^1].RuleId.Should().Be("unknown.final_score");
|
||||
proof.Nodes[^1].ParentIds.Should().ContainSingle().Which.Should().EndWith("-pressure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RankWithProof_ReturnsItemAndProofWithMatchingFinalScore()
|
||||
{
|
||||
var unknown = CreateUnknown();
|
||||
var blast = new BlastRadius(42, true, "admin");
|
||||
var pressure = new ExploitPressure(0.6, false);
|
||||
var containment = new ContainmentSignals("audit", "rw", "audit", 5);
|
||||
IUnknownRanker ranker = new UnknownRanker();
|
||||
IUnknownProofEmitter emitter = new UnknownProofEmitter();
|
||||
|
||||
var result = ranker.RankWithProof(emitter, unknown, blast, pressure, containment);
|
||||
|
||||
result.Item.Id.Should().Be(unknown.Id.ToString());
|
||||
result.Proof.Count.Should().BeGreaterThan(0);
|
||||
result.Proof.Nodes[^1].RuleId.Should().Be("unknown.final_score");
|
||||
result.Proof.Nodes[^1].Total.Should().Be(result.Item.Score);
|
||||
}
|
||||
|
||||
private static Unknown CreateUnknown()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 11, 10, 0, 0, TimeSpan.Zero);
|
||||
return new Unknown
|
||||
{
|
||||
Id = Guid.Parse("d20da73d-a148-48cd-8fd5-f721bd6b78ed"),
|
||||
TenantId = "tenant-qa",
|
||||
SubjectHash = "sha256:unknown-hash",
|
||||
SubjectType = UnknownSubjectType.Binary,
|
||||
SubjectRef = "/usr/lib/libssl.so.1.1",
|
||||
Kind = UnknownKind.MissingBuildId,
|
||||
Severity = UnknownSeverity.Medium,
|
||||
ValidFrom = now,
|
||||
SysFrom = now,
|
||||
CreatedAt = now,
|
||||
CreatedBy = "qa-test",
|
||||
UncertaintyScore = 0.65
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Core.Repositories;
|
||||
using StellaOps.Unknowns.Services;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Core.Tests.Services;
|
||||
|
||||
public sealed class UnknownsSlaBehaviorTests
|
||||
{
|
||||
private static readonly UnknownsSlaOptions DefaultOptions = new()
|
||||
{
|
||||
PollingInterval = TimeSpan.FromMilliseconds(50),
|
||||
HotSla = TimeSpan.FromHours(24),
|
||||
WarmSla = TimeSpan.FromDays(7),
|
||||
ColdSla = TimeSpan.FromDays(30),
|
||||
WarningThreshold = 0.80
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsHealthy_WhenAllPendingEntriesWithinSla()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 11, 11, 0, 0, TimeSpan.Zero);
|
||||
var repository = Substitute.For<IGreyQueueRepository>();
|
||||
repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([CreateEntry(now.AddHours(-2), 0.80)]));
|
||||
|
||||
var sut = new UnknownsSlaHealthCheck(repository, Options.Create(DefaultOptions), new FrozenTimeProvider(now));
|
||||
var result = await sut.CheckHealthAsync(new HealthCheckContext(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsDegraded_WhenPendingEntryIsNearSlaLimit()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 11, 11, 0, 0, TimeSpan.Zero);
|
||||
var repository = Substitute.For<IGreyQueueRepository>();
|
||||
repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([CreateEntry(now.AddHours(-20), 0.80)]));
|
||||
|
||||
var sut = new UnknownsSlaHealthCheck(repository, Options.Create(DefaultOptions), new FrozenTimeProvider(now));
|
||||
var result = await sut.CheckHealthAsync(new HealthCheckContext(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsUnhealthy_WhenPendingEntryBreachesSla()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 11, 11, 0, 0, TimeSpan.Zero);
|
||||
var repository = Substitute.For<IGreyQueueRepository>();
|
||||
repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([CreateEntry(now.AddHours(-30), 0.80)]));
|
||||
|
||||
var sut = new UnknownsSlaHealthCheck(repository, Options.Create(DefaultOptions), new FrozenTimeProvider(now));
|
||||
var result = await sut.CheckHealthAsync(new HealthCheckContext(), CancellationToken.None);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SlaMonitor_PublishesWarningAndBreachNotifications()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 2, 11, 11, 0, 0, TimeSpan.Zero);
|
||||
var repository = Substitute.For<IGreyQueueRepository>();
|
||||
var notifications = new CaptureNotificationPublisher();
|
||||
|
||||
var warning = CreateEntry(now.AddHours(-20), 0.80); // 83% of 24h
|
||||
var breach = CreateEntry(now.AddHours(-30), 0.80); // 125% of 24h
|
||||
|
||||
repository.GetPendingAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<GreyQueueEntry>>([warning, breach]));
|
||||
|
||||
var sut = new UnknownsSlaMonitorService(
|
||||
repository,
|
||||
notifications,
|
||||
Options.Create(DefaultOptions),
|
||||
new FrozenTimeProvider(now),
|
||||
new UnknownsMetrics(),
|
||||
NullLogger<UnknownsSlaMonitorService>.Instance);
|
||||
|
||||
var privateMethod = typeof(UnknownsSlaMonitorService).GetMethod(
|
||||
"CheckSlaBoundsAsync",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
privateMethod.Should().NotBeNull();
|
||||
|
||||
var task = (Task?)privateMethod!.Invoke(sut, [CancellationToken.None]);
|
||||
task.Should().NotBeNull();
|
||||
await task!;
|
||||
|
||||
notifications.Warnings.Should().ContainSingle(x => x.EntryId == warning.Id);
|
||||
notifications.Breaches.Should().ContainSingle(x => x.EntryId == breach.Id);
|
||||
}
|
||||
|
||||
private static GreyQueueEntry CreateEntry(DateTimeOffset createdAt, double score)
|
||||
{
|
||||
return new GreyQueueEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = "test-tenant",
|
||||
UnknownId = Guid.NewGuid(),
|
||||
BomRef = "pkg:npm/example@1.0.0",
|
||||
Fingerprint = $"sha256:{Guid.NewGuid():N}",
|
||||
Status = GreyQueueStatus.Pending,
|
||||
Priority = 100,
|
||||
Score = score,
|
||||
Reason = GreyQueueReason.InsufficientVex,
|
||||
CreatedAt = createdAt,
|
||||
CreatedBy = "qa-test"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FrozenTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
|
||||
private sealed class CaptureNotificationPublisher : INotificationPublisher
|
||||
{
|
||||
public List<SlaWarningNotification> Warnings { get; } = [];
|
||||
public List<SlaBreachNotification> Breaches { get; } = [];
|
||||
|
||||
public Task PublishAsync<T>(T notification, CancellationToken ct = default)
|
||||
{
|
||||
if (notification is SlaWarningNotification warning)
|
||||
{
|
||||
Warnings.Add(warning);
|
||||
}
|
||||
else if (notification is SlaBreachNotification breach)
|
||||
{
|
||||
Breaches.Add(breach);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user