feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies. - Documented roles and guidelines in AGENTS.md for Scheduler module. - Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs. - Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics. - Developed API endpoints for managing resolver jobs and retrieving metrics. - Defined models for resolver job requests and responses. - Integrated dependency injection for resolver job services. - Implemented ImpactIndexSnapshot for persisting impact index data. - Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring. - Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService. - Created dotnet-filter.sh script to handle command-line arguments for dotnet. - Established nuget-prime project for managing package downloads.
This commit is contained in:
@@ -15,9 +15,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}"
|
||||
EndProject
|
||||
Global
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals.Tests", "__Tests\StellaOps.Signals.Tests\StellaOps.Signals.Tests.csproj", "{1AB74DBC-22F8-48B8-B921-2367FFD67866}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
@@ -103,16 +105,28 @@ Global
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1AB74DBC-22F8-48B8-B921-2367FFD67866}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -29,6 +29,11 @@ public sealed class SignalsOptions
|
||||
/// Air-gap configuration.
|
||||
/// </summary>
|
||||
public SignalsAirGapOptions AirGap { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reachability scoring configuration.
|
||||
/// </summary>
|
||||
public SignalsScoringOptions Scoring { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validates configured options.
|
||||
@@ -39,5 +44,6 @@ public sealed class SignalsOptions
|
||||
Mongo.Validate();
|
||||
Storage.Validate();
|
||||
AirGap.Validate();
|
||||
Scoring.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Configurable weights used by reachability scoring.
|
||||
/// </summary>
|
||||
public sealed class SignalsScoringOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Confidence assigned when a path exists from entry point to target.
|
||||
/// </summary>
|
||||
public double ReachableConfidence { get; set; } = 0.75;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence assigned when no path exists from entry point to target.
|
||||
/// </summary>
|
||||
public double UnreachableConfidence { get; set; } = 0.25;
|
||||
|
||||
/// <summary>
|
||||
/// Bonus applied when runtime evidence matches the discovered path.
|
||||
/// </summary>
|
||||
public double RuntimeBonus { get; set; } = 0.15;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum confidence permitted after bonuses are applied.
|
||||
/// </summary>
|
||||
public double MaxConfidence { get; set; } = 0.99;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence permitted after penalties are applied.
|
||||
/// </summary>
|
||||
public double MinConfidence { get; set; } = 0.05;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
EnsurePercent(nameof(ReachableConfidence), ReachableConfidence);
|
||||
EnsurePercent(nameof(UnreachableConfidence), UnreachableConfidence);
|
||||
EnsurePercent(nameof(RuntimeBonus), RuntimeBonus);
|
||||
EnsurePercent(nameof(MaxConfidence), MaxConfidence);
|
||||
EnsurePercent(nameof(MinConfidence), MinConfidence);
|
||||
|
||||
if (MinConfidence > UnreachableConfidence)
|
||||
{
|
||||
throw new ArgumentException("MinConfidence must be less than or equal to UnreachableConfidence.");
|
||||
}
|
||||
|
||||
if (UnreachableConfidence > ReachableConfidence)
|
||||
{
|
||||
throw new ArgumentException("UnreachableConfidence must be less than or equal to ReachableConfidence.");
|
||||
}
|
||||
|
||||
if (ReachableConfidence > MaxConfidence)
|
||||
{
|
||||
throw new ArgumentException("ReachableConfidence must be less than or equal to MaxConfidence.");
|
||||
}
|
||||
|
||||
if (MinConfidence >= MaxConfidence)
|
||||
{
|
||||
throw new ArgumentException("MinConfidence must be less than MaxConfidence.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsurePercent(string name, double value)
|
||||
{
|
||||
if (double.IsNaN(value) || value < 0.0 || value > 1.0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(name, value, "Value must be between 0 and 1.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,33 +4,32 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Options;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
{
|
||||
private const double ReachableConfidence = 0.75;
|
||||
private const double UnreachableConfidence = 0.25;
|
||||
private const double RuntimeBonus = 0.15;
|
||||
private const double MaxConfidence = 0.99;
|
||||
private const double MinConfidence = 0.05;
|
||||
|
||||
private readonly ICallgraphRepository callgraphRepository;
|
||||
private readonly IReachabilityFactRepository factRepository;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly SignalsScoringOptions scoringOptions;
|
||||
private readonly ILogger<ReachabilityScoringService> logger;
|
||||
|
||||
public ReachabilityScoringService(
|
||||
ICallgraphRepository callgraphRepository,
|
||||
IReachabilityFactRepository factRepository,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<SignalsOptions> options,
|
||||
ILogger<ReachabilityScoringService> logger)
|
||||
{
|
||||
this.callgraphRepository = callgraphRepository ?? throw new ArgumentNullException(nameof(callgraphRepository));
|
||||
this.factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.scoringOptions = options?.Value?.Scoring ?? throw new ArgumentNullException(nameof(options));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -89,16 +88,16 @@ public sealed class ReachabilityScoringService : IReachabilityScoringService
|
||||
{
|
||||
var path = FindPath(entryPoints, target, graph.Adjacency);
|
||||
var reachable = path is not null;
|
||||
var confidence = reachable ? ReachableConfidence : UnreachableConfidence;
|
||||
var confidence = reachable ? scoringOptions.ReachableConfidence : scoringOptions.UnreachableConfidence;
|
||||
|
||||
var runtimeEvidence = runtimeHits.Where(hit => path?.Contains(hit, StringComparer.Ordinal) == true)
|
||||
.ToList();
|
||||
if (runtimeEvidence.Count > 0)
|
||||
{
|
||||
confidence = Math.Min(MaxConfidence, confidence + RuntimeBonus);
|
||||
confidence = Math.Min(scoringOptions.MaxConfidence, confidence + scoringOptions.RuntimeBonus);
|
||||
}
|
||||
|
||||
confidence = Math.Clamp(confidence, MinConfidence, MaxConfidence);
|
||||
confidence = Math.Clamp(confidence, scoringOptions.MinConfidence, scoringOptions.MaxConfidence);
|
||||
|
||||
states.Add(new ReachabilityStateDocument
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
|
||||
@@ -13,16 +14,19 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
{
|
||||
private readonly IReachabilityFactRepository factRepository;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IReachabilityScoringService scoringService;
|
||||
private readonly ILogger<RuntimeFactsIngestionService> logger;
|
||||
|
||||
public RuntimeFactsIngestionService(
|
||||
IReachabilityFactRepository factRepository,
|
||||
TimeProvider timeProvider,
|
||||
IReachabilityScoringService scoringService,
|
||||
ILogger<RuntimeFactsIngestionService> logger)
|
||||
{
|
||||
this.factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.scoringService = scoringService ?? throw new ArgumentNullException(nameof(scoringService));
|
||||
this.logger = logger ?? NullLogger<RuntimeFactsIngestionService>.Instance;
|
||||
}
|
||||
|
||||
public async Task<RuntimeFactsIngestResponse> IngestAsync(RuntimeFactsIngestRequest request, CancellationToken cancellationToken)
|
||||
@@ -47,9 +51,15 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
var aggregated = AggregateRuntimeFacts(request.Events);
|
||||
document.RuntimeFacts = MergeRuntimeFacts(document.RuntimeFacts, aggregated);
|
||||
document.Metadata = MergeMetadata(document.Metadata, request.Metadata);
|
||||
document.Metadata ??= new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
document.Metadata.TryAdd("provenance.source", request.Metadata?.TryGetValue("source", out var source) == true ? source : "runtime");
|
||||
document.Metadata["provenance.ingestedAt"] = document.ComputedAt.ToString("O");
|
||||
document.Metadata["provenance.callgraphId"] = request.CallgraphId;
|
||||
|
||||
var persisted = await factRepository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await RecomputeReachabilityAsync(persisted, aggregated, request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation(
|
||||
"Stored {RuntimeFactCount} runtime fact(s) for subject {SubjectKey} (callgraph={CallgraphId}).",
|
||||
persisted.RuntimeFacts?.Count ?? 0,
|
||||
@@ -244,6 +254,67 @@ public sealed class RuntimeFactsIngestionService : IRuntimeFactsIngestionService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task RecomputeReachabilityAsync(
|
||||
ReachabilityFactDocument persisted,
|
||||
List<RuntimeFactDocument> aggregatedRuntimeFacts,
|
||||
RuntimeFactsIngestRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var targets = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (persisted.States is { Count: > 0 })
|
||||
{
|
||||
foreach (var state in persisted.States)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(state.Target))
|
||||
{
|
||||
targets.Add(state.Target.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var fact in aggregatedRuntimeFacts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(fact.SymbolId))
|
||||
{
|
||||
targets.Add(fact.SymbolId.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var runtimeHits = aggregatedRuntimeFacts
|
||||
.Select(f => f.SymbolId)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var requestMetadata = MergeMetadata(persisted.Metadata, request.Metadata);
|
||||
|
||||
var recomputeRequest = new ReachabilityRecomputeRequest
|
||||
{
|
||||
CallgraphId = request.CallgraphId,
|
||||
Subject = request.Subject,
|
||||
EntryPoints = persisted.EntryPoints ?? new List<string>(),
|
||||
Targets = targets.ToList(),
|
||||
RuntimeHits = runtimeHits,
|
||||
Metadata = requestMetadata
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await scoringService.RecomputeAsync(recomputeRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to recompute reachability after runtime ingestion for subject {SubjectKey}.", persisted.SubjectKey);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
public class ReachabilityScoringServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RecomputeAsync_UsesConfiguredWeights()
|
||||
{
|
||||
var callgraph = new CallgraphDocument
|
||||
{
|
||||
Id = "cg-1",
|
||||
Language = "java",
|
||||
Component = "demo",
|
||||
Version = "1.0.0",
|
||||
Nodes = new List<CallgraphNode>
|
||||
{
|
||||
new("main", "Main", "method", null, null, null),
|
||||
new("svc", "Svc", "method", null, null, null),
|
||||
new("target", "Target", "method", null, null, null)
|
||||
},
|
||||
Edges = new List<CallgraphEdge>
|
||||
{
|
||||
new("main", "svc", "call"),
|
||||
new("svc", "target", "call")
|
||||
}
|
||||
};
|
||||
|
||||
var callgraphRepository = new InMemoryCallgraphRepository(callgraph);
|
||||
var factRepository = new InMemoryReachabilityFactRepository();
|
||||
|
||||
var options = new SignalsOptions();
|
||||
options.Scoring.ReachableConfidence = 0.8;
|
||||
options.Scoring.UnreachableConfidence = 0.3;
|
||||
options.Scoring.RuntimeBonus = 0.1;
|
||||
options.Scoring.MaxConfidence = 0.95;
|
||||
options.Scoring.MinConfidence = 0.1;
|
||||
|
||||
var service = new ReachabilityScoringService(
|
||||
callgraphRepository,
|
||||
factRepository,
|
||||
TimeProvider.System,
|
||||
Options.Create(options),
|
||||
NullLogger<ReachabilityScoringService>.Instance);
|
||||
|
||||
var request = new ReachabilityRecomputeRequest
|
||||
{
|
||||
CallgraphId = callgraph.Id,
|
||||
Subject = new ReachabilitySubject { Component = "demo", Version = "1.0.0" },
|
||||
EntryPoints = new List<string> { "main" },
|
||||
Targets = new List<string> { "target" },
|
||||
RuntimeHits = new List<string> { "svc", "target" }
|
||||
};
|
||||
|
||||
var fact = await service.RecomputeAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(callgraph.Id, fact.CallgraphId);
|
||||
Assert.Single(fact.States);
|
||||
var state = fact.States[0];
|
||||
Assert.True(state.Reachable);
|
||||
Assert.Equal("target", state.Target);
|
||||
Assert.Equal(new[] { "main", "svc", "target" }, state.Path);
|
||||
Assert.Equal(0.9, state.Confidence, 2); // 0.8 + 0.1 runtime bonus
|
||||
Assert.Contains("svc", state.Evidence.RuntimeHits);
|
||||
Assert.Contains("target", state.Evidence.RuntimeHits);
|
||||
}
|
||||
|
||||
private sealed class InMemoryCallgraphRepository : ICallgraphRepository
|
||||
{
|
||||
private readonly CallgraphDocument document;
|
||||
|
||||
public InMemoryCallgraphRepository(CallgraphDocument document)
|
||||
{
|
||||
this.document = document;
|
||||
}
|
||||
|
||||
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(document.Id == id ? document : null);
|
||||
}
|
||||
|
||||
public Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
// Not needed for this test
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository
|
||||
{
|
||||
public ReachabilityFactDocument? Last;
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Last);
|
||||
}
|
||||
|
||||
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Last = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
public class RuntimeFactsIngestionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task IngestAsync_AggregatesHits_AndRecomputesReachability()
|
||||
{
|
||||
var factRepository = new InMemoryReachabilityFactRepository();
|
||||
var scoringService = new RecordingScoringService();
|
||||
var service = new RuntimeFactsIngestionService(
|
||||
factRepository,
|
||||
TimeProvider.System,
|
||||
scoringService,
|
||||
NullLogger<RuntimeFactsIngestionService>.Instance);
|
||||
|
||||
var request = new RuntimeFactsIngestRequest
|
||||
{
|
||||
Subject = new ReachabilitySubject { Component = "web", Version = "2.1.0" },
|
||||
CallgraphId = "cg-123",
|
||||
Metadata = new Dictionary<string, string?> { { "source", "runtime" } },
|
||||
Events = new List<RuntimeFactEvent>
|
||||
{
|
||||
new() { SymbolId = "svc.foo", HitCount = 2, Metadata = new Dictionary<string, string?> { { "pid", "12" } } },
|
||||
new() { SymbolId = "svc.bar", HitCount = 1 },
|
||||
new() { SymbolId = "svc.foo", HitCount = 3 }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await service.IngestAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal("web|2.1.0", response.SubjectKey);
|
||||
Assert.Equal("cg-123", response.CallgraphId);
|
||||
|
||||
var persisted = factRepository.Last ?? throw new Xunit.Sdk.XunitException("Fact not persisted");
|
||||
Assert.Equal(2, persisted.RuntimeFacts?.Count);
|
||||
|
||||
var foo = persisted.RuntimeFacts?.Single(f => f.SymbolId == "svc.foo");
|
||||
Assert.Equal(5, foo?.HitCount);
|
||||
|
||||
var bar = persisted.RuntimeFacts?.Single(f => f.SymbolId == "svc.bar");
|
||||
Assert.Equal(1, bar?.HitCount);
|
||||
|
||||
var recorded = scoringService.LastRequest ?? throw new Xunit.Sdk.XunitException("Recompute not triggered");
|
||||
Assert.Equal("cg-123", recorded.CallgraphId);
|
||||
Assert.Contains("svc.foo", recorded.Targets);
|
||||
Assert.Contains("svc.bar", recorded.RuntimeHits!);
|
||||
Assert.Equal("runtime", recorded.Metadata?["source"]);
|
||||
|
||||
Assert.Equal("runtime", persisted.Metadata?["provenance.source"]);
|
||||
Assert.Equal("cg-123", persisted.Metadata?["provenance.callgraphId"]);
|
||||
Assert.NotNull(persisted.Metadata?["provenance.ingestedAt"]);
|
||||
}
|
||||
|
||||
private sealed class InMemoryReachabilityFactRepository : IReachabilityFactRepository
|
||||
{
|
||||
public ReachabilityFactDocument? Last { get; private set; }
|
||||
|
||||
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Last);
|
||||
}
|
||||
|
||||
public Task<ReachabilityFactDocument> UpsertAsync(ReachabilityFactDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
Last = document;
|
||||
return Task.FromResult(document);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingScoringService : IReachabilityScoringService
|
||||
{
|
||||
public ReachabilityRecomputeRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<ReachabilityFactDocument> RecomputeAsync(ReachabilityRecomputeRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new ReachabilityFactDocument
|
||||
{
|
||||
CallgraphId = request.CallgraphId,
|
||||
Subject = request.Subject,
|
||||
SubjectKey = request.Subject?.ToSubjectKey() ?? string.Empty,
|
||||
EntryPoints = request.EntryPoints,
|
||||
States = new List<ReachabilityStateDocument>(),
|
||||
RuntimeFacts = new List<RuntimeFactDocument>()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Signals/StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user