Implement ledger metrics for observability and add tests for Ruby packages endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added `LedgerMetrics` class to record write latency and total events for ledger operations.
- Created comprehensive tests for Ruby packages endpoints, covering scenarios for missing inventory, successful retrieval, and identifier handling.
- Introduced `TestSurfaceSecretsScope` for managing environment variables during tests.
- Developed `ProvenanceMongoExtensions` for attaching DSSE provenance and trust information to event documents.
- Implemented `EventProvenanceWriter` and `EventWriter` classes for managing event provenance in MongoDB.
- Established MongoDB indexes for efficient querying of events based on provenance and trust.
- Added models and JSON parsing logic for DSSE provenance and trust information.
This commit is contained in:
master
2025-11-13 09:29:09 +02:00
parent 151f6b35cc
commit 61f963fd52
101 changed files with 5881 additions and 1776 deletions

View File

@@ -168,12 +168,16 @@ internal static class ScanEndpoints
var snapshot = await coordinator.GetAsync(parsed, context.RequestAborted).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
}
SurfacePointersDto? surfacePointers = null;
@@ -282,10 +286,12 @@ internal static class ScanEndpoints
private static async Task<IResult> HandleEntryTraceAsync(
string scanId,
IScanCoordinator coordinator,
IEntryTraceResultStore resultStore,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(resultStore);
if (!ScanId.TryParse(scanId, out var parsed))
@@ -298,15 +304,25 @@ internal static class ScanEndpoints
detail: "Scan identifier is required.");
}
var result = await resultStore.GetAsync(parsed.Value, cancellationToken).ConfigureAwait(false);
var targetScanId = parsed.Value;
var result = await resultStore.GetAsync(targetScanId, cancellationToken).ConfigureAwait(false);
if (result is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"EntryTrace not found",
StatusCodes.Status404NotFound,
detail: "EntryTrace data is not available for the requested scan.");
var snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
if (snapshot is not null && !string.Equals(snapshot.ScanId.Value, targetScanId, StringComparison.Ordinal))
{
result = await resultStore.GetAsync(snapshot.ScanId.Value, cancellationToken).ConfigureAwait(false);
}
if (result is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"EntryTrace not found",
StatusCodes.Status404NotFound,
detail: "EntryTrace data is not available for the requested scan.");
}
}
var response = new EntryTraceResponse(
@@ -321,10 +337,12 @@ internal static class ScanEndpoints
private static async Task<IResult> HandleRubyPackagesAsync(
string scanId,
IScanCoordinator coordinator,
IRubyPackageInventoryStore inventoryStore,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(inventoryStore);
if (!ScanId.TryParse(scanId, out var parsed))
@@ -340,12 +358,27 @@ internal static class ScanEndpoints
var inventory = await inventoryStore.GetAsync(parsed.Value, cancellationToken).ConfigureAwait(false);
if (inventory is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Ruby packages not found",
StatusCodes.Status404NotFound,
detail: "Ruby package inventory is not available for the requested scan.");
RubyPackageInventory? fallback = null;
if (!LooksLikeScanId(scanId))
{
var snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
if (snapshot is not null)
{
fallback = await inventoryStore.GetAsync(snapshot.ScanId.Value, cancellationToken).ConfigureAwait(false);
}
}
if (fallback is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Ruby packages not found",
StatusCodes.Status404NotFound,
detail: "Ruby package inventory is not available for the requested scan.");
}
inventory = fallback;
}
var response = new RubyPackagesResponse
@@ -420,4 +453,130 @@ internal static class ScanEndpoints
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static async ValueTask<ScanSnapshot?> TryResolveSnapshotAsync(
string identifier,
IScanCoordinator coordinator,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
if (string.IsNullOrWhiteSpace(identifier))
{
return null;
}
var trimmed = identifier.Trim();
var decoded = Uri.UnescapeDataString(trimmed);
if (LooksLikeScanId(decoded))
{
return null;
}
var (reference, digest) = ExtractTargetHints(decoded);
if (reference is null && digest is null)
{
return null;
}
return await coordinator.TryFindByTargetAsync(reference, digest, cancellationToken).ConfigureAwait(false);
}
private static (string? Reference, string? Digest) ExtractTargetHints(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
{
return (null, null);
}
var trimmed = identifier.Trim();
if (TryExtractDigest(trimmed, out var digest, out var reference))
{
return (reference, digest);
}
return (trimmed, null);
}
private static bool TryExtractDigest(string candidate, out string? digest, out string? reference)
{
var atIndex = candidate.IndexOf('@');
if (atIndex >= 0 && atIndex < candidate.Length - 1)
{
var digestCandidate = candidate[(atIndex + 1)..];
if (IsDigestValue(digestCandidate))
{
digest = digestCandidate.ToLowerInvariant();
reference = candidate[..atIndex].Trim();
if (string.IsNullOrWhiteSpace(reference))
{
reference = null;
}
return true;
}
}
if (IsDigestValue(candidate))
{
digest = candidate.ToLowerInvariant();
reference = null;
return true;
}
digest = null;
reference = null;
return false;
}
private static bool IsDigestValue(string value)
{
var separatorIndex = value.IndexOf(':');
if (separatorIndex <= 0 || separatorIndex >= value.Length - 1)
{
return false;
}
var algorithm = value[..separatorIndex];
var digestPart = value[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(algorithm) || string.IsNullOrWhiteSpace(digestPart) || digestPart.Length < 32)
{
return false;
}
foreach (var c in digestPart)
{
if (!IsHexChar(c))
{
return false;
}
}
return true;
}
private static bool LooksLikeScanId(string value)
{
if (value.Length != 40)
{
return false;
}
foreach (var c in value)
{
if (!IsHexChar(c))
{
return false;
}
}
return true;
}
private static bool IsHexChar(char c)
=> (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
}

View File

@@ -2,9 +2,11 @@ using StellaOps.Scanner.WebService.Domain;
namespace StellaOps.Scanner.WebService.Services;
public interface IScanCoordinator
{
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
}
public interface IScanCoordinator
{
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken);
}

View File

@@ -7,11 +7,13 @@ namespace StellaOps.Scanner.WebService.Services;
public sealed class InMemoryScanCoordinator : IScanCoordinator
{
private sealed record ScanEntry(ScanSnapshot Snapshot);
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
private readonly IScanProgressPublisher progressPublisher;
private sealed record ScanEntry(ScanSnapshot Snapshot);
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> scansByDigest = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> scansByReference = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider timeProvider;
private readonly IScanProgressPublisher progressPublisher;
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
{
@@ -37,12 +39,12 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
eventData[$"meta.{pair.Key}"] = pair.Value;
}
ScanEntry entry = scans.AddOrUpdate(
scanId.Value,
_ => new ScanEntry(new ScanSnapshot(
scanId,
normalizedTarget,
ScanStatus.Pending,
ScanEntry entry = scans.AddOrUpdate(
scanId.Value,
_ => new ScanEntry(new ScanSnapshot(
scanId,
normalizedTarget,
ScanStatus.Pending,
now,
now,
null)),
@@ -59,22 +61,87 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
return new ScanEntry(snapshot);
}
return existing;
});
var created = entry.Snapshot.CreatedAt == now;
var state = entry.Snapshot.Status.ToString();
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
}
return existing;
});
IndexTarget(scanId.Value, normalizedTarget);
var created = entry.Snapshot.CreatedAt == now;
var state = entry.Snapshot.Status.ToString();
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
}
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
{
if (scans.TryGetValue(scanId.Value, out var entry))
{
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
}
if (scans.TryGetValue(scanId.Value, out var entry))
{
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(digest))
{
var normalizedDigest = NormalizeDigest(digest);
if (normalizedDigest is not null &&
scansByDigest.TryGetValue(normalizedDigest, out var digestScanId) &&
scans.TryGetValue(digestScanId, out var digestEntry))
{
return ValueTask.FromResult<ScanSnapshot?>(digestEntry.Snapshot);
}
}
if (!string.IsNullOrWhiteSpace(reference))
{
var normalizedReference = NormalizeReference(reference);
if (normalizedReference is not null &&
scansByReference.TryGetValue(normalizedReference, out var referenceScanId) &&
scans.TryGetValue(referenceScanId, out var referenceEntry))
{
return ValueTask.FromResult<ScanSnapshot?>(referenceEntry.Snapshot);
}
}
return ValueTask.FromResult<ScanSnapshot?>(null);
}
private void IndexTarget(string scanId, ScanTarget target)
{
if (!string.IsNullOrWhiteSpace(target.Digest))
{
scansByDigest[target.Digest!] = scanId;
}
if (!string.IsNullOrWhiteSpace(target.Reference))
{
scansByReference[target.Reference!] = scanId;
}
}
private static string? NormalizeDigest(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Contains(':', StringComparison.Ordinal)
? trimmed.ToLowerInvariant()
: null;
}
private static string? NormalizeReference(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim();
}
}