Implement ledger metrics for observability and add tests for Ruby packages endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user