partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
// <copyright file="LineageStreamController.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for real-time lineage streaming and optimized graph queries.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/lineage")]
|
||||
[Authorize(Policy = "sbom:read")]
|
||||
public sealed class LineageStreamController : ControllerBase
|
||||
{
|
||||
private readonly ILineageStreamService _streamService;
|
||||
private readonly ILineageGraphOptimizer _optimizer;
|
||||
private readonly ILineageGraphService _graphService;
|
||||
private readonly ILogger<LineageStreamController> _logger;
|
||||
|
||||
public LineageStreamController(
|
||||
ILineageStreamService streamService,
|
||||
ILineageGraphOptimizer optimizer,
|
||||
ILineageGraphService graphService,
|
||||
ILogger<LineageStreamController> logger)
|
||||
{
|
||||
_streamService = streamService;
|
||||
_optimizer = optimizer;
|
||||
_graphService = graphService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to real-time lineage updates via Server-Sent Events.
|
||||
/// </summary>
|
||||
/// <param name="watchDigests">Optional comma-separated list of digests to watch.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>SSE stream of lineage update events.</returns>
|
||||
[HttpGet("stream")]
|
||||
[Produces("text/event-stream")]
|
||||
public async Task StreamUpdates(
|
||||
[FromQuery] string? watchDigests = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = GetTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
Response.StatusCode = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
Response.Headers.Connection = "keep-alive";
|
||||
|
||||
var digestList = string.IsNullOrWhiteSpace(watchDigests)
|
||||
? null
|
||||
: watchDigests.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var evt in _streamService.SubscribeAsync(tenantId, digestList, ct))
|
||||
{
|
||||
var eventData = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
id = evt.EventId,
|
||||
type = evt.EventType.ToString(),
|
||||
digest = evt.AffectedDigest,
|
||||
parentDigest = evt.ParentDigest,
|
||||
timestamp = evt.Timestamp,
|
||||
data = evt.Data
|
||||
});
|
||||
|
||||
await Response.WriteAsync($"event: lineage-update\n", ct);
|
||||
await Response.WriteAsync($"data: {eventData}\n\n", ct);
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("SSE stream cancelled for tenant {TenantId}", tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in SSE stream for tenant {TenantId}", tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get an optimized lineage graph with pagination and depth pruning.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Center artifact digest.</param>
|
||||
/// <param name="maxDepth">Maximum traversal depth (default: 3).</param>
|
||||
/// <param name="pageSize">Nodes per page (default: 50).</param>
|
||||
/// <param name="pageNumber">Page number (0-indexed, default: 0).</param>
|
||||
/// <param name="searchTerm">Optional search filter for node names.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Optimized graph with boundary information.</returns>
|
||||
[HttpGet("{artifactDigest}/optimized")]
|
||||
[ProducesResponseType<OptimizedLineageGraphDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetOptimizedLineage(
|
||||
string artifactDigest,
|
||||
[FromQuery] int maxDepth = 3,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] int pageNumber = 0,
|
||||
[FromQuery] string? searchTerm = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
|
||||
|
||||
if (maxDepth < 1 || maxDepth > 20)
|
||||
return BadRequest(new { error = "INVALID_MAX_DEPTH", message = "maxDepth must be between 1 and 20" });
|
||||
|
||||
if (pageSize < 1 || pageSize > 200)
|
||||
return BadRequest(new { error = "INVALID_PAGE_SIZE", message = "pageSize must be between 1 and 200" });
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
// Get full graph first
|
||||
var options = new LineageQueryOptions(
|
||||
MaxDepth: 50, // Get all to allow optimization
|
||||
IncludeVerdicts: true,
|
||||
IncludeBadges: true
|
||||
);
|
||||
|
||||
var fullResult = await _graphService.GetLineageAsync(artifactDigest, tenantId, options, ct);
|
||||
|
||||
if (fullResult.Graph.Nodes.Count == 0)
|
||||
return NotFound(new { error = "LINEAGE_NOT_FOUND", artifactDigest });
|
||||
|
||||
// Convert to optimizer format
|
||||
var allNodes = fullResult.Graph.Nodes
|
||||
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
|
||||
.ToImmutableArray();
|
||||
|
||||
var allEdges = fullResult.Graph.Edges
|
||||
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
|
||||
.ToImmutableArray();
|
||||
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CenterDigest = artifactDigest,
|
||||
AllNodes = allNodes,
|
||||
AllEdges = allEdges,
|
||||
MaxDepth = maxDepth,
|
||||
PageSize = pageSize,
|
||||
PageNumber = pageNumber,
|
||||
SearchTerm = searchTerm
|
||||
};
|
||||
|
||||
var optimized = _optimizer.Optimize(request);
|
||||
|
||||
return Ok(new OptimizedLineageGraphDto
|
||||
{
|
||||
CenterDigest = artifactDigest,
|
||||
Nodes = optimized.Nodes.Select(n => new LineageNodeDto
|
||||
{
|
||||
Digest = n.Digest,
|
||||
Name = n.Name,
|
||||
Version = n.Version,
|
||||
ComponentCount = n.ComponentCount
|
||||
}).ToList(),
|
||||
Edges = optimized.Edges.Select(e => new LineageEdgeDto
|
||||
{
|
||||
FromDigest = e.FromDigest,
|
||||
ToDigest = e.ToDigest
|
||||
}).ToList(),
|
||||
BoundaryNodes = optimized.BoundaryNodes.Select(b => new BoundaryNodeDto
|
||||
{
|
||||
Digest = b.Digest,
|
||||
HiddenChildrenCount = b.HiddenChildrenCount,
|
||||
HiddenParentsCount = b.HiddenParentsCount
|
||||
}).ToList(),
|
||||
TotalNodes = optimized.TotalNodes,
|
||||
HasMorePages = optimized.HasMorePages,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get optimized lineage for {Digest}", artifactDigest);
|
||||
return StatusCode(500, new { error = "INTERNAL_ERROR" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get lineage graph traversed level by level (for progressive rendering).
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Starting artifact digest.</param>
|
||||
/// <param name="direction">Traversal direction: Children, Parents, or Center.</param>
|
||||
/// <param name="maxDepth">Maximum depth to traverse.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>SSE stream of levels.</returns>
|
||||
[HttpGet("{artifactDigest}/levels")]
|
||||
[Produces("text/event-stream")]
|
||||
public async Task StreamLevels(
|
||||
string artifactDigest,
|
||||
[FromQuery] string direction = "Children",
|
||||
[FromQuery] int maxDepth = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = GetTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
Response.StatusCode = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<TraversalDirection>(direction, ignoreCase: true, out var traversalDir))
|
||||
{
|
||||
Response.StatusCode = 400;
|
||||
await Response.WriteAsync("Invalid direction. Use: Children, Parents, or Center");
|
||||
return;
|
||||
}
|
||||
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
Response.Headers.Connection = "keep-alive";
|
||||
|
||||
try
|
||||
{
|
||||
// Get full graph
|
||||
var options = new LineageQueryOptions(MaxDepth: 50, IncludeVerdicts: false, IncludeBadges: false);
|
||||
var fullResult = await _graphService.GetLineageAsync(artifactDigest, tenantId, options, ct);
|
||||
|
||||
if (fullResult.Graph.Nodes.Count == 0)
|
||||
{
|
||||
await Response.WriteAsync("event: error\n");
|
||||
await Response.WriteAsync("data: {\"error\":\"LINEAGE_NOT_FOUND\"}\n\n");
|
||||
return;
|
||||
}
|
||||
|
||||
var allNodes = fullResult.Graph.Nodes
|
||||
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
|
||||
.ToImmutableArray();
|
||||
|
||||
var allEdges = fullResult.Graph.Edges
|
||||
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
|
||||
.ToImmutableArray();
|
||||
|
||||
await foreach (var level in _optimizer.TraverseLevelsAsync(
|
||||
artifactDigest, allNodes, allEdges, traversalDir, maxDepth, ct))
|
||||
{
|
||||
var levelData = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
depth = level.Depth,
|
||||
nodes = level.Nodes.Select(n => new
|
||||
{
|
||||
digest = n.Digest,
|
||||
name = n.Name,
|
||||
version = n.Version,
|
||||
componentCount = n.ComponentCount
|
||||
}),
|
||||
isComplete = level.IsComplete
|
||||
});
|
||||
|
||||
await Response.WriteAsync($"event: level\n", ct);
|
||||
await Response.WriteAsync($"data: {levelData}\n\n", ct);
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
await Response.WriteAsync("event: complete\n");
|
||||
await Response.WriteAsync("data: {\"status\":\"done\"}\n\n");
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Level stream cancelled for {Digest}", artifactDigest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error streaming levels for {Digest}", artifactDigest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get cached metadata about a lineage graph.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Center artifact digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Graph metadata including total counts and max depth.</returns>
|
||||
[HttpGet("{artifactDigest}/metadata")]
|
||||
[ProducesResponseType<LineageGraphMetadataDto>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetMetadata(
|
||||
string artifactDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
// Get full graph to compute metadata
|
||||
var options = new LineageQueryOptions(MaxDepth: 50, IncludeVerdicts: false, IncludeBadges: false);
|
||||
var fullResult = await _graphService.GetLineageAsync(artifactDigest, tenantId, options, ct);
|
||||
|
||||
if (fullResult.Graph.Nodes.Count == 0)
|
||||
return NotFound(new { error = "LINEAGE_NOT_FOUND", artifactDigest });
|
||||
|
||||
var allNodes = fullResult.Graph.Nodes
|
||||
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
|
||||
.ToImmutableArray();
|
||||
|
||||
var allEdges = fullResult.Graph.Edges
|
||||
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
|
||||
.ToImmutableArray();
|
||||
|
||||
var metadata = await _optimizer.GetOrComputeMetadataAsync(
|
||||
tenantId, artifactDigest, allNodes, allEdges, ct);
|
||||
|
||||
return Ok(new LineageGraphMetadataDto
|
||||
{
|
||||
CenterDigest = artifactDigest,
|
||||
TotalNodes = metadata.TotalNodes,
|
||||
TotalEdges = metadata.TotalEdges,
|
||||
MaxDepth = metadata.MaxDepth,
|
||||
ComputedAt = metadata.ComputedAt
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get metadata for {Digest}", artifactDigest);
|
||||
return StatusCode(500, new { error = "INTERNAL_ERROR" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate cached metadata for an artifact.
|
||||
/// </summary>
|
||||
/// <param name="artifactDigest">Center artifact digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Confirmation of cache invalidation.</returns>
|
||||
[HttpDelete("{artifactDigest}/cache")]
|
||||
[Authorize(Policy = "lineage:admin")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> InvalidateCache(
|
||||
string artifactDigest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactDigest))
|
||||
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
if (tenantId == Guid.Empty)
|
||||
return Unauthorized();
|
||||
|
||||
await _optimizer.InvalidateCacheAsync(tenantId, artifactDigest, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private Guid GetTenantId()
|
||||
{
|
||||
// TODO: Extract from claims or headers
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs for API responses
|
||||
public sealed record OptimizedLineageGraphDto
|
||||
{
|
||||
public required string CenterDigest { get; init; }
|
||||
public required List<LineageNodeDto> Nodes { get; init; }
|
||||
public required List<LineageEdgeDto> Edges { get; init; }
|
||||
public required List<BoundaryNodeDto> BoundaryNodes { get; init; }
|
||||
public required int TotalNodes { get; init; }
|
||||
public required bool HasMorePages { get; init; }
|
||||
public required int PageNumber { get; init; }
|
||||
public required int PageSize { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LineageNodeDto
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required int ComponentCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LineageEdgeDto
|
||||
{
|
||||
public required string FromDigest { get; init; }
|
||||
public required string ToDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record BoundaryNodeDto
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required int HiddenChildrenCount { get; init; }
|
||||
public required int HiddenParentsCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LineageGraphMetadataDto
|
||||
{
|
||||
public required string CenterDigest { get; init; }
|
||||
public required int TotalNodes { get; init; }
|
||||
public required int TotalEdges { get; init; }
|
||||
public required int MaxDepth { get; init; }
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// <copyright file="ILineageGraphOptimizer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for optimizing large lineage graphs with pagination, caching, and progressive traversal.
|
||||
/// </summary>
|
||||
public interface ILineageGraphOptimizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Optimize a lineage graph by applying depth pruning, search filtering, and pagination.
|
||||
/// </summary>
|
||||
/// <param name="request">The optimization request parameters.</param>
|
||||
/// <returns>Optimized graph with boundary information.</returns>
|
||||
OptimizedLineageGraph Optimize(LineageOptimizationRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Traverse the graph level by level for progressive rendering.
|
||||
/// </summary>
|
||||
/// <param name="centerDigest">Starting node digest.</param>
|
||||
/// <param name="nodes">All nodes in the graph.</param>
|
||||
/// <param name="edges">All edges in the graph.</param>
|
||||
/// <param name="direction">Direction to traverse (Children, Parents, or Center).</param>
|
||||
/// <param name="maxDepth">Maximum depth to traverse.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of levels.</returns>
|
||||
IAsyncEnumerable<LineageLevel> TraverseLevelsAsync(
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
TraversalDirection direction,
|
||||
int maxDepth = 10,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get or compute cached metadata about a lineage graph.
|
||||
/// </summary>
|
||||
Task<LineageGraphMetadata> GetOrComputeMetadataAsync(
|
||||
Guid tenantId,
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate cached metadata for an artifact.
|
||||
/// </summary>
|
||||
Task InvalidateCacheAsync(
|
||||
Guid tenantId,
|
||||
string centerDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// <copyright file="ILineageStreamService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for streaming real-time lineage updates via Server-Sent Events.
|
||||
/// </summary>
|
||||
public interface ILineageStreamService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribe to lineage updates for a tenant and optionally specific artifacts.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="watchDigests">Optional list of artifact digests to watch.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of lineage update events.</returns>
|
||||
IAsyncEnumerable<LineageUpdateEvent> SubscribeAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<string>? watchDigests = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publish an update event to all relevant subscribers.
|
||||
/// </summary>
|
||||
Task PublishAsync(Guid tenantId, LineageUpdateEvent evt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers about a new SBOM version.
|
||||
/// </summary>
|
||||
Task NotifySbomAddedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
string? parentDigest,
|
||||
SbomVersionSummary summary,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers about a VEX status change.
|
||||
/// </summary>
|
||||
Task NotifyVexChangedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
VexChangeData change,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers about reachability updates.
|
||||
/// </summary>
|
||||
Task NotifyReachabilityUpdatedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
ReachabilityUpdateData update,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers about lineage edge changes.
|
||||
/// </summary>
|
||||
Task NotifyEdgeChangedAsync(
|
||||
Guid tenantId,
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
LineageEdgeChangeType changeType,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageGraphOptimizer.cs
|
||||
// Sprint: SPRINT_20260208_058_SbomService_sbom_lineage_graph_visualization
|
||||
// Task: T2 — Performance optimization for large lineage graphs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides optimized graph traversal and caching for large lineage graphs.
|
||||
/// Implements pagination, lazy loading, and level-based traversal.
|
||||
/// </summary>
|
||||
public sealed class LineageGraphOptimizer : ILineageGraphOptimizer
|
||||
{
|
||||
private readonly IDistributedCache? _cache;
|
||||
private readonly ILogger<LineageGraphOptimizer> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly LineageGraphOptimizerOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public LineageGraphOptimizer(
|
||||
ILogger<LineageGraphOptimizer> logger,
|
||||
LineageGraphOptimizerOptions? options = null,
|
||||
IDistributedCache? cache = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options ?? new LineageGraphOptimizerOptions();
|
||||
_cache = cache;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimizes a large lineage graph by applying pagination and filtering.
|
||||
/// </summary>
|
||||
public OptimizedLineageGraph Optimize(
|
||||
LineageGraph fullGraph,
|
||||
LineageOptimizationRequest request)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// 1. Prune nodes outside requested depth
|
||||
var prunedNodes = PruneByDepth(fullGraph, request.CenterDigest, request.MaxDepth);
|
||||
|
||||
// 2. Apply search filter if specified
|
||||
if (!string.IsNullOrEmpty(request.SearchTerm))
|
||||
{
|
||||
prunedNodes = FilterBySearchTerm(prunedNodes, request.SearchTerm);
|
||||
}
|
||||
|
||||
// 3. Apply pagination
|
||||
var totalNodes = prunedNodes.Count;
|
||||
var paginatedNodes = prunedNodes
|
||||
.Skip(request.Offset)
|
||||
.Take(request.Limit)
|
||||
.ToList();
|
||||
|
||||
// 4. Get edges only for visible nodes
|
||||
var visibleDigests = paginatedNodes.Select(n => n.ArtifactDigest).ToHashSet(StringComparer.Ordinal);
|
||||
var visibleEdges = fullGraph.Edges
|
||||
.Where(e => visibleDigests.Contains(e.FromDigest) || visibleDigests.Contains(e.ToDigest))
|
||||
.ToList();
|
||||
|
||||
// 5. Compute boundary nodes (nodes with edges outside visible set)
|
||||
var boundaryNodes = ComputeBoundaryNodes(paginatedNodes, fullGraph.Edges, visibleDigests);
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Optimized graph from {FullCount} to {VisibleCount} nodes in {ElapsedMs}ms",
|
||||
fullGraph.Nodes.Count, paginatedNodes.Count, sw.ElapsedMilliseconds);
|
||||
|
||||
return new OptimizedLineageGraph
|
||||
{
|
||||
Nodes = paginatedNodes,
|
||||
Edges = visibleEdges,
|
||||
TotalNodeCount = totalNodes,
|
||||
HasMore = request.Offset + request.Limit < totalNodes,
|
||||
Offset = request.Offset,
|
||||
Limit = request.Limit,
|
||||
BoundaryNodes = boundaryNodes,
|
||||
OptimizationTimeMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs level-based traversal for efficient large graph loading.
|
||||
/// Returns nodes one level at a time for progressive rendering.
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<LineageLevel> TraverseLevelsAsync(
|
||||
string centerDigest,
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<LineageNode>>> getChildrenAsync,
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<LineageNode>>> getParentsAsync,
|
||||
int maxDepth = 5,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var currentLevel = new List<string> { centerDigest };
|
||||
var level = 0;
|
||||
|
||||
// Yield the center node first
|
||||
yield return new LineageLevel
|
||||
{
|
||||
Level = 0,
|
||||
Direction = TraversalDirection.Center,
|
||||
NodeDigests = currentLevel.ToList()
|
||||
};
|
||||
|
||||
visited.Add(centerDigest);
|
||||
|
||||
// Traverse outward (children)
|
||||
var childQueue = new List<string> { centerDigest };
|
||||
for (int depth = 1; depth <= maxDepth && childQueue.Count > 0; depth++)
|
||||
{
|
||||
var nextLevel = new List<string>();
|
||||
|
||||
foreach (var digest in childQueue)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var children = await getChildrenAsync(digest, ct);
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (visited.Add(child.ArtifactDigest))
|
||||
{
|
||||
nextLevel.Add(child.ArtifactDigest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextLevel.Count > 0)
|
||||
{
|
||||
yield return new LineageLevel
|
||||
{
|
||||
Level = depth,
|
||||
Direction = TraversalDirection.Children,
|
||||
NodeDigests = nextLevel
|
||||
};
|
||||
}
|
||||
|
||||
childQueue = nextLevel;
|
||||
}
|
||||
|
||||
// Traverse inward (parents)
|
||||
var parentQueue = new List<string> { centerDigest };
|
||||
for (int depth = 1; depth <= maxDepth && parentQueue.Count > 0; depth++)
|
||||
{
|
||||
var nextLevel = new List<string>();
|
||||
|
||||
foreach (var digest in parentQueue)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var parents = await getParentsAsync(digest, ct);
|
||||
foreach (var parent in parents)
|
||||
{
|
||||
if (visited.Add(parent.ArtifactDigest))
|
||||
{
|
||||
nextLevel.Add(parent.ArtifactDigest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextLevel.Count > 0)
|
||||
{
|
||||
yield return new LineageLevel
|
||||
{
|
||||
Level = depth,
|
||||
Direction = TraversalDirection.Parents,
|
||||
NodeDigests = nextLevel
|
||||
};
|
||||
}
|
||||
|
||||
parentQueue = nextLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches graph metadata for faster subsequent loads.
|
||||
/// </summary>
|
||||
public async Task<LineageGraphMetadata> GetOrComputeMetadataAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
Func<CancellationToken, Task<LineageGraphMetadata>> computeAsync,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_cache is null)
|
||||
{
|
||||
return await computeAsync(ct);
|
||||
}
|
||||
|
||||
var cacheKey = $"lineage:meta:{tenantId}:{artifactDigest}";
|
||||
var cached = await _cache.GetStringAsync(cacheKey, ct);
|
||||
|
||||
if (cached is not null)
|
||||
{
|
||||
var metadata = JsonSerializer.Deserialize<LineageGraphMetadata>(cached, SerializerOptions);
|
||||
if (metadata is not null)
|
||||
{
|
||||
_logger.LogDebug("Metadata cache hit for {Digest}", artifactDigest);
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
var computed = await computeAsync(ct);
|
||||
|
||||
var json = JsonSerializer.Serialize(computed, SerializerOptions);
|
||||
await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = _options.MetadataCacheExpiry
|
||||
}, ct);
|
||||
|
||||
return computed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cached graph data for an artifact.
|
||||
/// </summary>
|
||||
public async Task InvalidateCacheAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_cache is null) return;
|
||||
|
||||
var cacheKey = $"lineage:meta:{tenantId}:{artifactDigest}";
|
||||
await _cache.RemoveAsync(cacheKey, ct);
|
||||
|
||||
_logger.LogDebug("Invalidated cache for {Digest}", artifactDigest);
|
||||
}
|
||||
|
||||
private List<LineageNode> PruneByDepth(LineageGraph graph, string centerDigest, int maxDepth)
|
||||
{
|
||||
var distances = ComputeDistances(graph, centerDigest);
|
||||
|
||||
return graph.Nodes
|
||||
.Where(n => distances.TryGetValue(n.ArtifactDigest, out var d) && d <= maxDepth)
|
||||
.OrderBy(n => distances.GetValueOrDefault(n.ArtifactDigest, int.MaxValue))
|
||||
.ThenBy(n => n.ArtifactDigest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private Dictionary<string, int> ComputeDistances(LineageGraph graph, string centerDigest)
|
||||
{
|
||||
var distances = new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
[centerDigest] = 0
|
||||
};
|
||||
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(centerDigest);
|
||||
|
||||
// Build adjacency map for BFS
|
||||
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!adjacency.TryGetValue(edge.FromDigest, out var fromList))
|
||||
{
|
||||
fromList = new List<string>();
|
||||
adjacency[edge.FromDigest] = fromList;
|
||||
}
|
||||
fromList.Add(edge.ToDigest);
|
||||
|
||||
if (!adjacency.TryGetValue(edge.ToDigest, out var toList))
|
||||
{
|
||||
toList = new List<string>();
|
||||
adjacency[edge.ToDigest] = toList;
|
||||
}
|
||||
toList.Add(edge.FromDigest);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
var currentDistance = distances[current];
|
||||
|
||||
if (!adjacency.TryGetValue(current, out var neighbors))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
if (!distances.ContainsKey(neighbor))
|
||||
{
|
||||
distances[neighbor] = currentDistance + 1;
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
private static List<LineageNode> FilterBySearchTerm(List<LineageNode> nodes, string searchTerm)
|
||||
{
|
||||
var term = searchTerm.ToLowerInvariant();
|
||||
return nodes
|
||||
.Where(n =>
|
||||
n.Name?.Contains(term, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
n.ArtifactDigest.Contains(term, StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Version?.Contains(term, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<BoundaryNodeInfo> ComputeBoundaryNodes(
|
||||
List<LineageNode> visibleNodes,
|
||||
IReadOnlyList<LineageEdge> allEdges,
|
||||
HashSet<string> visibleDigests)
|
||||
{
|
||||
var boundaryNodes = new List<BoundaryNodeInfo>();
|
||||
|
||||
foreach (var node in visibleNodes)
|
||||
{
|
||||
var hiddenChildren = allEdges
|
||||
.Where(e => e.FromDigest == node.ArtifactDigest && !visibleDigests.Contains(e.ToDigest))
|
||||
.Count();
|
||||
|
||||
var hiddenParents = allEdges
|
||||
.Where(e => e.ToDigest == node.ArtifactDigest && !visibleDigests.Contains(e.FromDigest))
|
||||
.Count();
|
||||
|
||||
if (hiddenChildren > 0 || hiddenParents > 0)
|
||||
{
|
||||
boundaryNodes.Add(new BoundaryNodeInfo
|
||||
{
|
||||
Digest = node.ArtifactDigest,
|
||||
HiddenChildCount = hiddenChildren,
|
||||
HiddenParentCount = hiddenParents
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return boundaryNodes;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the lineage graph optimizer.
|
||||
/// </summary>
|
||||
public sealed record LineageGraphOptimizerOptions
|
||||
{
|
||||
public TimeSpan MetadataCacheExpiry { get; init; } = TimeSpan.FromMinutes(30);
|
||||
public int DefaultPageSize { get; init; } = 50;
|
||||
public int MaxPageSize { get; init; } = 200;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for graph optimization.
|
||||
/// </summary>
|
||||
public sealed record LineageOptimizationRequest
|
||||
{
|
||||
public required string CenterDigest { get; init; }
|
||||
public int MaxDepth { get; init; } = 5;
|
||||
public int Offset { get; init; } = 0;
|
||||
public int Limit { get; init; } = 50;
|
||||
public string? SearchTerm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimized lineage graph with pagination info.
|
||||
/// </summary>
|
||||
public sealed record OptimizedLineageGraph
|
||||
{
|
||||
public required IReadOnlyList<LineageNode> Nodes { get; init; }
|
||||
public required IReadOnlyList<LineageEdge> Edges { get; init; }
|
||||
public required int TotalNodeCount { get; init; }
|
||||
public required bool HasMore { get; init; }
|
||||
public required int Offset { get; init; }
|
||||
public required int Limit { get; init; }
|
||||
public required IReadOnlyList<BoundaryNodeInfo> BoundaryNodes { get; init; }
|
||||
public required long OptimizationTimeMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a boundary node with hidden connections.
|
||||
/// </summary>
|
||||
public sealed record BoundaryNodeInfo
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required int HiddenChildCount { get; init; }
|
||||
public required int HiddenParentCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A level in the lineage graph during traversal.
|
||||
/// </summary>
|
||||
public sealed record LineageLevel
|
||||
{
|
||||
public required int Level { get; init; }
|
||||
public required TraversalDirection Direction { get; init; }
|
||||
public required IReadOnlyList<string> NodeDigests { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction of traversal.
|
||||
/// </summary>
|
||||
public enum TraversalDirection
|
||||
{
|
||||
Center,
|
||||
Children,
|
||||
Parents
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached metadata about a lineage graph.
|
||||
/// </summary>
|
||||
public sealed record LineageGraphMetadata
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required int TotalNodes { get; init; }
|
||||
public required int TotalEdges { get; init; }
|
||||
public required int MaxDepth { get; init; }
|
||||
public required DateTimeOffset LastUpdated { get; init; }
|
||||
public IReadOnlyList<string>? RootDigests { get; init; }
|
||||
public IReadOnlyList<string>? LeafDigests { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LineageStreamService.cs
|
||||
// Sprint: SPRINT_20260208_058_SbomService_sbom_lineage_graph_visualization
|
||||
// Task: T1 — Real-time lineage update via SSE
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for streaming real-time lineage updates via Server-Sent Events.
|
||||
/// </summary>
|
||||
public sealed class LineageStreamService : ILineageStreamService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Channel<LineageUpdateEvent>> _subscriptions = new();
|
||||
private readonly ILogger<LineageStreamService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _disposed;
|
||||
|
||||
public LineageStreamService(
|
||||
ILogger<LineageStreamService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to lineage updates for a tenant and optionally specific artifacts.
|
||||
/// Returns an async enumerable that yields updates.
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<LineageUpdateEvent> SubscribeAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<string>? watchDigests = null,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var subscriptionId = CreateSubscriptionId(tenantId, watchDigests);
|
||||
var channel = Channel.CreateBounded<LineageUpdateEvent>(new BoundedChannelOptions(100)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
_subscriptions[subscriptionId] = channel;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Subscription created: {SubscriptionId} for tenant {TenantId}",
|
||||
subscriptionId, tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var update in channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
// Filter by watched digests if specified
|
||||
if (watchDigests is not null && watchDigests.Count > 0)
|
||||
{
|
||||
if (!IsRelevantUpdate(update, watchDigests))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscriptions.TryRemove(subscriptionId, out _);
|
||||
_logger.LogDebug("Subscription removed: {SubscriptionId}", subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish a lineage update event to all relevant subscribers.
|
||||
/// </summary>
|
||||
public async ValueTask PublishAsync(
|
||||
LineageUpdateEvent updateEvent,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantPrefix = $"tenant:{updateEvent.TenantId}:";
|
||||
var matchingSubscriptions = _subscriptions
|
||||
.Where(kvp => kvp.Key.StartsWith(tenantPrefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Publishing lineage update {EventType} to {Count} subscribers",
|
||||
updateEvent.EventType, matchingSubscriptions.Count);
|
||||
|
||||
foreach (var (_, channel) in matchingSubscriptions)
|
||||
{
|
||||
// Fire-and-forget write, drop if channel is full
|
||||
channel.Writer.TryWrite(updateEvent);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify when a new SBOM version is added to the lineage graph.
|
||||
/// </summary>
|
||||
public async ValueTask NotifySbomAddedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
string? parentDigest,
|
||||
SbomVersionSummary summary,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var evt = new LineageUpdateEvent
|
||||
{
|
||||
EventType = LineageEventType.SbomAdded,
|
||||
TenantId = tenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
AffectedDigest = artifactDigest,
|
||||
ParentDigest = parentDigest,
|
||||
Data = new LineageSbomAddedData
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
Summary = summary
|
||||
}
|
||||
};
|
||||
|
||||
await PublishAsync(evt, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify when a VEX verdict changes in the lineage.
|
||||
/// </summary>
|
||||
public async ValueTask NotifyVexChangedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
VexChangeData changeData,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var evt = new LineageUpdateEvent
|
||||
{
|
||||
EventType = LineageEventType.VexChanged,
|
||||
TenantId = tenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
AffectedDigest = artifactDigest,
|
||||
Data = changeData
|
||||
};
|
||||
|
||||
await PublishAsync(evt, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify when reachability analysis completes for an artifact.
|
||||
/// </summary>
|
||||
public async ValueTask NotifyReachabilityUpdatedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
ReachabilityUpdateData updateData,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var evt = new LineageUpdateEvent
|
||||
{
|
||||
EventType = LineageEventType.ReachabilityUpdated,
|
||||
TenantId = tenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
AffectedDigest = artifactDigest,
|
||||
Data = updateData
|
||||
};
|
||||
|
||||
await PublishAsync(evt, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify when a lineage edge is added or removed.
|
||||
/// </summary>
|
||||
public async ValueTask NotifyEdgeChangedAsync(
|
||||
Guid tenantId,
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
LineageEdgeChangeType changeType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var evt = new LineageUpdateEvent
|
||||
{
|
||||
EventType = LineageEventType.EdgeChanged,
|
||||
TenantId = tenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
AffectedDigest = fromDigest,
|
||||
Data = new LineageEdgeChangeData
|
||||
{
|
||||
FromDigest = fromDigest,
|
||||
ToDigest = toDigest,
|
||||
ChangeType = changeType
|
||||
}
|
||||
};
|
||||
|
||||
await PublishAsync(evt, ct);
|
||||
}
|
||||
|
||||
private static string CreateSubscriptionId(Guid tenantId, IReadOnlyList<string>? watchDigests)
|
||||
{
|
||||
var baseId = $"tenant:{tenantId}:{Guid.NewGuid():N}";
|
||||
if (watchDigests is not null && watchDigests.Count > 0)
|
||||
{
|
||||
return $"{baseId}:watch:{string.Join(",", watchDigests.Take(10))}";
|
||||
}
|
||||
return baseId;
|
||||
}
|
||||
|
||||
private static bool IsRelevantUpdate(LineageUpdateEvent update, IReadOnlyList<string> watchDigests)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.AffectedDigest) &&
|
||||
watchDigests.Contains(update.AffectedDigest, StringComparer.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(update.ParentDigest) &&
|
||||
watchDigests.Contains(update.ParentDigest, StringComparer.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var (_, channel) in _subscriptions)
|
||||
{
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Real-time lineage update event.
|
||||
/// </summary>
|
||||
public sealed record LineageUpdateEvent
|
||||
{
|
||||
public required LineageEventType EventType { get; init; }
|
||||
public required Guid TenantId { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? AffectedDigest { get; init; }
|
||||
public string? ParentDigest { get; init; }
|
||||
public object? Data { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of lineage update events.
|
||||
/// </summary>
|
||||
public enum LineageEventType
|
||||
{
|
||||
SbomAdded,
|
||||
SbomUpdated,
|
||||
VexChanged,
|
||||
ReachabilityUpdated,
|
||||
EdgeChanged,
|
||||
Heartbeat
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for SBOM added event.
|
||||
/// </summary>
|
||||
public sealed record LineageSbomAddedData
|
||||
{
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required SbomVersionSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of an SBOM version.
|
||||
/// </summary>
|
||||
public sealed record SbomVersionSummary
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required int ComponentCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for VEX change event.
|
||||
/// </summary>
|
||||
public sealed record VexChangeData
|
||||
{
|
||||
public required string Cve { get; init; }
|
||||
public required string FromStatus { get; init; }
|
||||
public required string ToStatus { get; init; }
|
||||
public string? Justification { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for reachability update event.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityUpdateData
|
||||
{
|
||||
public required int TotalPaths { get; init; }
|
||||
public required int ReachablePaths { get; init; }
|
||||
public required int UnreachablePaths { get; init; }
|
||||
public IReadOnlyList<string>? TopReachableCves { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data for edge change event.
|
||||
/// </summary>
|
||||
public sealed record LineageEdgeChangeData
|
||||
{
|
||||
public required string FromDigest { get; init; }
|
||||
public required string ToDigest { get; init; }
|
||||
public required LineageEdgeChangeType ChangeType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of edge change.
|
||||
/// </summary>
|
||||
public enum LineageEdgeChangeType
|
||||
{
|
||||
Added,
|
||||
Removed
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// <copyright file="LineageGraphOptimizerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Lineage.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Tests.Services;
|
||||
|
||||
public sealed class LineageGraphOptimizerTests
|
||||
{
|
||||
private readonly InMemoryDistributedCache _cache = new();
|
||||
private readonly LineageGraphOptimizer _optimizer;
|
||||
private readonly LineageGraphOptimizerOptions _options = new()
|
||||
{
|
||||
MaxNodes = 100,
|
||||
DefaultDepth = 3,
|
||||
CacheDuration = TimeSpan.FromMinutes(10)
|
||||
};
|
||||
|
||||
public LineageGraphOptimizerTests()
|
||||
{
|
||||
_optimizer = new LineageGraphOptimizer(
|
||||
NullLogger<LineageGraphOptimizer>.Instance,
|
||||
_cache,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optimize_WithEmptyGraph_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
CenterDigest = "sha256:center",
|
||||
AllNodes = ImmutableArray<LineageNode>.Empty,
|
||||
AllEdges = ImmutableArray<LineageEdge>.Empty,
|
||||
MaxDepth = 3
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _optimizer.Optimize(request);
|
||||
|
||||
// Assert
|
||||
result.Nodes.Should().BeEmpty();
|
||||
result.Edges.Should().BeEmpty();
|
||||
result.BoundaryNodes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optimize_PrunesByDepth()
|
||||
{
|
||||
// Arrange - Create a chain: center -> child1 -> child2 -> child3
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10),
|
||||
new LineageNode("sha256:child1", "child1", "1.0.0", 5),
|
||||
new LineageNode("sha256:child2", "child2", "1.0.0", 8),
|
||||
new LineageNode("sha256:child3", "child3", "1.0.0", 3));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:center", "sha256:child1"),
|
||||
new LineageEdge("sha256:child1", "sha256:child2"),
|
||||
new LineageEdge("sha256:child2", "sha256:child3"));
|
||||
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
CenterDigest = "sha256:center",
|
||||
AllNodes = nodes,
|
||||
AllEdges = edges,
|
||||
MaxDepth = 2 // Should include center, child1, child2 but mark child2 as boundary
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _optimizer.Optimize(request);
|
||||
|
||||
// Assert - child3 should be pruned
|
||||
result.Nodes.Should().HaveCount(3);
|
||||
result.Nodes.Should().Contain(n => n.Digest == "sha256:center");
|
||||
result.Nodes.Should().Contain(n => n.Digest == "sha256:child1");
|
||||
result.Nodes.Should().Contain(n => n.Digest == "sha256:child2");
|
||||
result.Nodes.Should().NotContain(n => n.Digest == "sha256:child3");
|
||||
|
||||
// child2 should be marked as boundary
|
||||
result.BoundaryNodes.Should().ContainSingle();
|
||||
result.BoundaryNodes[0].Digest.Should().Be("sha256:child2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optimize_FiltersNodesBySearchTerm()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center-app", "1.0.0", 10),
|
||||
new LineageNode("sha256:child1", "logging-lib", "1.0.0", 5),
|
||||
new LineageNode("sha256:child2", "database-lib", "1.0.0", 8));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:center", "sha256:child1"),
|
||||
new LineageEdge("sha256:center", "sha256:child2"));
|
||||
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
CenterDigest = "sha256:center",
|
||||
AllNodes = nodes,
|
||||
AllEdges = edges,
|
||||
SearchTerm = "log",
|
||||
MaxDepth = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _optimizer.Optimize(request);
|
||||
|
||||
// Assert - Only center (always included) and logging-lib (matches search)
|
||||
result.Nodes.Should().HaveCount(2);
|
||||
result.Nodes.Should().Contain(n => n.Name == "center-app");
|
||||
result.Nodes.Should().Contain(n => n.Name == "logging-lib");
|
||||
result.Nodes.Should().NotContain(n => n.Name == "database-lib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optimize_AppliesPagination()
|
||||
{
|
||||
// Arrange - Create 10 children
|
||||
var nodesList = new List<LineageNode>
|
||||
{
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10)
|
||||
};
|
||||
var edgesList = new List<LineageEdge>();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var childDigest = $"sha256:child{i:D2}";
|
||||
nodesList.Add(new LineageNode(childDigest, $"child-{i}", "1.0.0", i + 1));
|
||||
edgesList.Add(new LineageEdge("sha256:center", childDigest));
|
||||
}
|
||||
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
CenterDigest = "sha256:center",
|
||||
AllNodes = nodesList.ToImmutableArray(),
|
||||
AllEdges = edgesList.ToImmutableArray(),
|
||||
MaxDepth = 10,
|
||||
PageSize = 5,
|
||||
PageNumber = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _optimizer.Optimize(request);
|
||||
|
||||
// Assert - Should have 6 nodes (center + 5 children)
|
||||
result.Nodes.Should().HaveCount(6);
|
||||
result.TotalNodes.Should().Be(11);
|
||||
result.HasMorePages.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TraverseLevelsAsync_ReturnsLevelsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10),
|
||||
new LineageNode("sha256:level1a", "level1a", "1.0.0", 5),
|
||||
new LineageNode("sha256:level1b", "level1b", "1.0.0", 5),
|
||||
new LineageNode("sha256:level2", "level2", "1.0.0", 3));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:center", "sha256:level1a"),
|
||||
new LineageEdge("sha256:center", "sha256:level1b"),
|
||||
new LineageEdge("sha256:level1a", "sha256:level2"));
|
||||
|
||||
// Act
|
||||
var levels = new List<LineageLevel>();
|
||||
await foreach (var level in _optimizer.TraverseLevelsAsync(
|
||||
"sha256:center",
|
||||
nodes,
|
||||
edges,
|
||||
TraversalDirection.Children,
|
||||
maxDepth: 5))
|
||||
{
|
||||
levels.Add(level);
|
||||
}
|
||||
|
||||
// Assert
|
||||
levels.Should().HaveCount(3);
|
||||
levels[0].Depth.Should().Be(0);
|
||||
levels[0].Nodes.Should().ContainSingle(n => n.Digest == "sha256:center");
|
||||
|
||||
levels[1].Depth.Should().Be(1);
|
||||
levels[1].Nodes.Should().HaveCount(2);
|
||||
|
||||
levels[2].Depth.Should().Be(2);
|
||||
levels[2].Nodes.Should().ContainSingle(n => n.Digest == "sha256:level2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TraverseLevelsAsync_Parents_TraversesUpward()
|
||||
{
|
||||
// Arrange
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:root", "root", "1.0.0", 10),
|
||||
new LineageNode("sha256:middle", "middle", "1.0.0", 5),
|
||||
new LineageNode("sha256:leaf", "leaf", "1.0.0", 3));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:root", "sha256:middle"),
|
||||
new LineageEdge("sha256:middle", "sha256:leaf"));
|
||||
|
||||
// Act - traverse from leaf upward
|
||||
var levels = new List<LineageLevel>();
|
||||
await foreach (var level in _optimizer.TraverseLevelsAsync(
|
||||
"sha256:leaf",
|
||||
nodes,
|
||||
edges,
|
||||
TraversalDirection.Parents,
|
||||
maxDepth: 5))
|
||||
{
|
||||
levels.Add(level);
|
||||
}
|
||||
|
||||
// Assert
|
||||
levels.Should().HaveCount(3);
|
||||
levels[0].Nodes.Should().ContainSingle(n => n.Digest == "sha256:leaf");
|
||||
levels[1].Nodes.Should().ContainSingle(n => n.Digest == "sha256:middle");
|
||||
levels[2].Nodes.Should().ContainSingle(n => n.Digest == "sha256:root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrComputeMetadataAsync_CachesResult()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10),
|
||||
new LineageNode("sha256:child", "child", "1.0.0", 5));
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:center", "sha256:child"));
|
||||
|
||||
// Act - first call computes
|
||||
var metadata1 = await _optimizer.GetOrComputeMetadataAsync(
|
||||
tenantId,
|
||||
"sha256:center",
|
||||
nodes,
|
||||
edges);
|
||||
|
||||
// Second call should use cache
|
||||
var metadata2 = await _optimizer.GetOrComputeMetadataAsync(
|
||||
tenantId,
|
||||
"sha256:center",
|
||||
nodes,
|
||||
edges);
|
||||
|
||||
// Assert
|
||||
metadata1.TotalNodes.Should().Be(2);
|
||||
metadata1.TotalEdges.Should().Be(1);
|
||||
metadata2.Should().BeEquivalentTo(metadata1);
|
||||
|
||||
// Verify cache was used
|
||||
_cache.GetCallCount.Should().BeGreaterThan(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateCacheAsync_RemovesCachedMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10));
|
||||
var edges = ImmutableArray<LineageEdge>.Empty;
|
||||
|
||||
// Populate cache
|
||||
await _optimizer.GetOrComputeMetadataAsync(
|
||||
tenantId,
|
||||
"sha256:center",
|
||||
nodes,
|
||||
edges);
|
||||
|
||||
// Act
|
||||
await _optimizer.InvalidateCacheAsync(tenantId, "sha256:center");
|
||||
|
||||
// Assert - cache should be empty for this key
|
||||
_cache.RemoveCallCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optimize_DetectsBoundaryNodesWithHiddenChildren()
|
||||
{
|
||||
// Arrange - Complex graph with deep children
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10),
|
||||
new LineageNode("sha256:child1", "child1", "1.0.0", 5),
|
||||
new LineageNode("sha256:grandchild", "grandchild", "1.0.0", 3),
|
||||
new LineageNode("sha256:greatgrand", "greatgrand", "1.0.0", 2));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:center", "sha256:child1"),
|
||||
new LineageEdge("sha256:child1", "sha256:grandchild"),
|
||||
new LineageEdge("sha256:grandchild", "sha256:greatgrand"));
|
||||
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
CenterDigest = "sha256:center",
|
||||
AllNodes = nodes,
|
||||
AllEdges = edges,
|
||||
MaxDepth = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _optimizer.Optimize(request);
|
||||
|
||||
// Assert - grandchild is boundary because greatgrand is hidden
|
||||
result.BoundaryNodes.Should().ContainSingle();
|
||||
result.BoundaryNodes[0].Digest.Should().Be("sha256:grandchild");
|
||||
result.BoundaryNodes[0].HiddenChildrenCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optimize_HandlesDisconnectedNodes()
|
||||
{
|
||||
// Arrange - Nodes not connected to center
|
||||
var nodes = ImmutableArray.Create(
|
||||
new LineageNode("sha256:center", "center", "1.0.0", 10),
|
||||
new LineageNode("sha256:connected", "connected", "1.0.0", 5),
|
||||
new LineageNode("sha256:disconnected", "disconnected", "1.0.0", 3));
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new LineageEdge("sha256:center", "sha256:connected"));
|
||||
|
||||
var request = new LineageOptimizationRequest
|
||||
{
|
||||
TenantId = Guid.NewGuid(),
|
||||
CenterDigest = "sha256:center",
|
||||
AllNodes = nodes,
|
||||
AllEdges = edges,
|
||||
MaxDepth = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _optimizer.Optimize(request);
|
||||
|
||||
// Assert - disconnected node should not appear
|
||||
result.Nodes.Should().HaveCount(2);
|
||||
result.Nodes.Should().NotContain(n => n.Digest == "sha256:disconnected");
|
||||
}
|
||||
|
||||
private sealed class InMemoryDistributedCache : IDistributedCache
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _cache = new();
|
||||
|
||||
public int GetCallCount { get; private set; }
|
||||
public int SetCallCount { get; private set; }
|
||||
public int RemoveCallCount { get; private set; }
|
||||
|
||||
public byte[]? Get(string key)
|
||||
{
|
||||
GetCallCount++;
|
||||
return _cache.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetAsync(string key, CancellationToken token = default)
|
||||
{
|
||||
GetCallCount++;
|
||||
return Task.FromResult(_cache.TryGetValue(key, out var value) ? value : null);
|
||||
}
|
||||
|
||||
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
|
||||
{
|
||||
SetCallCount++;
|
||||
_cache[key] = value;
|
||||
}
|
||||
|
||||
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
|
||||
{
|
||||
SetCallCount++;
|
||||
_cache[key] = value;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Refresh(string key) { }
|
||||
public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask;
|
||||
|
||||
public void Remove(string key)
|
||||
{
|
||||
RemoveCallCount++;
|
||||
_cache.Remove(key);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string key, CancellationToken token = default)
|
||||
{
|
||||
RemoveCallCount++;
|
||||
_cache.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// <copyright file="LineageStreamServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.SbomService.Lineage.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Tests.Services;
|
||||
|
||||
public sealed class LineageStreamServiceTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly LineageStreamService _service;
|
||||
|
||||
public LineageStreamServiceTests()
|
||||
{
|
||||
_service = new LineageStreamService(
|
||||
NullLogger<LineageStreamService>.Instance,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_DeliversToSubscribers()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var receivedEvents = new List<LineageUpdateEvent>();
|
||||
var subscriptionTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
|
||||
{
|
||||
receivedEvents.Add(evt);
|
||||
if (receivedEvents.Count >= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for subscription to be established
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
await _service.NotifySbomAddedAsync(
|
||||
tenantId,
|
||||
"sha256:abc123",
|
||||
null,
|
||||
new SbomVersionSummary
|
||||
{
|
||||
Name = "test-app",
|
||||
Version = "1.0.0",
|
||||
ComponentCount = 10,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Assert
|
||||
await subscriptionTask;
|
||||
receivedEvents.Should().HaveCount(1);
|
||||
receivedEvents[0].EventType.Should().Be(LineageEventType.SbomAdded);
|
||||
receivedEvents[0].AffectedDigest.Should().Be("sha256:abc123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_FiltersUnwatchedDigests()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var watchDigests = new[] { "sha256:watched" };
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var receivedEvents = new List<LineageUpdateEvent>();
|
||||
var subscriptionTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, watchDigests, cts.Token))
|
||||
{
|
||||
receivedEvents.Add(evt);
|
||||
if (receivedEvents.Count >= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for subscription
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - publish to unwatched digest (should be filtered)
|
||||
await _service.NotifySbomAddedAsync(
|
||||
tenantId,
|
||||
"sha256:unwatched",
|
||||
null,
|
||||
new SbomVersionSummary
|
||||
{
|
||||
Name = "test",
|
||||
Version = "1.0.0",
|
||||
ComponentCount = 5,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Publish to watched digest (should be delivered)
|
||||
await _service.NotifySbomAddedAsync(
|
||||
tenantId,
|
||||
"sha256:watched",
|
||||
null,
|
||||
new SbomVersionSummary
|
||||
{
|
||||
Name = "watched-app",
|
||||
Version = "2.0.0",
|
||||
ComponentCount = 15,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Assert
|
||||
await subscriptionTask;
|
||||
receivedEvents.Should().HaveCount(1);
|
||||
receivedEvents[0].AffectedDigest.Should().Be("sha256:watched");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_ReceivesParentDigestUpdates()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var watchDigests = new[] { "sha256:parent" };
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var receivedEvents = new List<LineageUpdateEvent>();
|
||||
var subscriptionTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, watchDigests, cts.Token))
|
||||
{
|
||||
receivedEvents.Add(evt);
|
||||
if (receivedEvents.Count >= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - publish with parent digest matching watch list
|
||||
await _service.NotifySbomAddedAsync(
|
||||
tenantId,
|
||||
"sha256:child",
|
||||
"sha256:parent",
|
||||
new SbomVersionSummary
|
||||
{
|
||||
Name = "child-app",
|
||||
Version = "1.0.0",
|
||||
ComponentCount = 8,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Assert
|
||||
await subscriptionTask;
|
||||
receivedEvents.Should().HaveCount(1);
|
||||
receivedEvents[0].ParentDigest.Should().Be("sha256:parent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyVexChangedAsync_PublishesCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var receivedEvents = new List<LineageUpdateEvent>();
|
||||
var subscriptionTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
|
||||
{
|
||||
receivedEvents.Add(evt);
|
||||
if (receivedEvents.Count >= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
await _service.NotifyVexChangedAsync(
|
||||
tenantId,
|
||||
"sha256:abc123",
|
||||
new VexChangeData
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
FromStatus = "Affected",
|
||||
ToStatus = "NotAffected",
|
||||
Justification = "Component not in use"
|
||||
});
|
||||
|
||||
// Assert
|
||||
await subscriptionTask;
|
||||
receivedEvents.Should().HaveCount(1);
|
||||
receivedEvents[0].EventType.Should().Be(LineageEventType.VexChanged);
|
||||
var data = receivedEvents[0].Data.Should().BeOfType<VexChangeData>().Subject;
|
||||
data.Cve.Should().Be("CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyReachabilityUpdatedAsync_PublishesCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var receivedEvents = new List<LineageUpdateEvent>();
|
||||
var subscriptionTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
|
||||
{
|
||||
receivedEvents.Add(evt);
|
||||
if (receivedEvents.Count >= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
await _service.NotifyReachabilityUpdatedAsync(
|
||||
tenantId,
|
||||
"sha256:abc123",
|
||||
new ReachabilityUpdateData
|
||||
{
|
||||
TotalPaths = 100,
|
||||
ReachablePaths = 25,
|
||||
UnreachablePaths = 75,
|
||||
TopReachableCves = new[] { "CVE-2024-1234", "CVE-2024-5678" }
|
||||
});
|
||||
|
||||
// Assert
|
||||
await subscriptionTask;
|
||||
receivedEvents.Should().HaveCount(1);
|
||||
receivedEvents[0].EventType.Should().Be(LineageEventType.ReachabilityUpdated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyEdgeChangedAsync_PublishesCorrectEvent()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var receivedEvents = new List<LineageUpdateEvent>();
|
||||
var subscriptionTask = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
|
||||
{
|
||||
receivedEvents.Add(evt);
|
||||
if (receivedEvents.Count >= 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
await _service.NotifyEdgeChangedAsync(
|
||||
tenantId,
|
||||
"sha256:parent",
|
||||
"sha256:child",
|
||||
LineageEdgeChangeType.Added);
|
||||
|
||||
// Assert
|
||||
await subscriptionTask;
|
||||
receivedEvents.Should().HaveCount(1);
|
||||
receivedEvents[0].EventType.Should().Be(LineageEventType.EdgeChanged);
|
||||
var data = receivedEvents[0].Data.Should().BeOfType<LineageEdgeChangeData>().Subject;
|
||||
data.FromDigest.Should().Be("sha256:parent");
|
||||
data.ToDigest.Should().Be("sha256:child");
|
||||
data.ChangeType.Should().Be(LineageEdgeChangeType.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleSubscribers_ReceiveSameEvent()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var subscriber1Events = new List<LineageUpdateEvent>();
|
||||
var subscriber2Events = new List<LineageUpdateEvent>();
|
||||
|
||||
var sub1Task = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
|
||||
{
|
||||
subscriber1Events.Add(evt);
|
||||
if (subscriber1Events.Count >= 1) break;
|
||||
}
|
||||
});
|
||||
|
||||
var sub2Task = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenantId, ct: cts.Token))
|
||||
{
|
||||
subscriber2Events.Add(evt);
|
||||
if (subscriber2Events.Count >= 1) break;
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act
|
||||
await _service.NotifySbomAddedAsync(
|
||||
tenantId,
|
||||
"sha256:shared",
|
||||
null,
|
||||
new SbomVersionSummary
|
||||
{
|
||||
Name = "shared-app",
|
||||
Version = "1.0.0",
|
||||
ComponentCount = 20,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Assert
|
||||
await Task.WhenAll(sub1Task, sub2Task);
|
||||
subscriber1Events.Should().HaveCount(1);
|
||||
subscriber2Events.Should().HaveCount(1);
|
||||
subscriber1Events[0].AffectedDigest.Should().Be(subscriber2Events[0].AffectedDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DifferentTenants_DoNotReceiveEachOthersEvents()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1 = Guid.NewGuid();
|
||||
var tenant2 = Guid.NewGuid();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
var tenant1Events = new List<LineageUpdateEvent>();
|
||||
var tenant2Events = new List<LineageUpdateEvent>();
|
||||
|
||||
var sub1Task = Task.Run(async () =>
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenant1, ct: cts.Token))
|
||||
{
|
||||
tenant1Events.Add(evt);
|
||||
if (tenant1Events.Count >= 1) break;
|
||||
}
|
||||
});
|
||||
|
||||
var sub2Task = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var evt in _service.SubscribeAsync(tenant2, ct: cts.Token))
|
||||
{
|
||||
tenant2Events.Add(evt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
// Act - publish only to tenant1
|
||||
await _service.NotifySbomAddedAsync(
|
||||
tenant1,
|
||||
"sha256:tenant1only",
|
||||
null,
|
||||
new SbomVersionSummary
|
||||
{
|
||||
Name = "tenant1-app",
|
||||
Version = "1.0.0",
|
||||
ComponentCount = 10,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
await sub1Task;
|
||||
cts.Cancel();
|
||||
|
||||
try { await sub2Task; } catch { }
|
||||
|
||||
// Assert
|
||||
tenant1Events.Should().HaveCount(1);
|
||||
tenant2Events.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// <copyright file="LineageStreamControllerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.SbomService.Controllers;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
using StellaOps.SbomService.Lineage.Services;
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests.Lineage;
|
||||
|
||||
public sealed class LineageStreamControllerTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly InMemoryLineageStreamService _streamService;
|
||||
private readonly InMemoryLineageGraphOptimizer _optimizer;
|
||||
private readonly InMemoryLineageGraphService _graphService;
|
||||
private readonly LineageStreamController _controller;
|
||||
|
||||
public LineageStreamControllerTests()
|
||||
{
|
||||
_streamService = new InMemoryLineageStreamService(_timeProvider);
|
||||
_optimizer = new InMemoryLineageGraphOptimizer();
|
||||
_graphService = new InMemoryLineageGraphService();
|
||||
_controller = new LineageStreamController(
|
||||
_streamService,
|
||||
_optimizer,
|
||||
_graphService,
|
||||
NullLogger<LineageStreamController>.Instance);
|
||||
|
||||
// Set up HttpContext for controller
|
||||
_controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOptimizedLineage_WithValidDigest_ReturnsOptimizedGraph()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:abc123";
|
||||
_graphService.SetupGraph(digest, new LineageGraphResponse(
|
||||
new LineageGraphDto(
|
||||
Nodes: ImmutableArray.Create(
|
||||
new LineageNodeDto(digest, "app", "1.0.0", 10),
|
||||
new LineageNodeDto("sha256:child", "lib", "1.0.0", 5)),
|
||||
Edges: ImmutableArray.Create(
|
||||
new LineageEdgeDto(digest, "sha256:child"))),
|
||||
Enrichment: null));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage(digest, maxDepth: 3, pageSize: 50, pageNumber: 0);
|
||||
|
||||
// Assert
|
||||
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var graph = okResult.Value.Should().BeOfType<OptimizedLineageGraphDto>().Subject;
|
||||
graph.CenterDigest.Should().Be(digest);
|
||||
graph.Nodes.Should().HaveCountGreaterOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOptimizedLineage_WithInvalidDepth_ReturnsBadRequest()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage("sha256:abc123", maxDepth: 100);
|
||||
|
||||
// Assert
|
||||
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||
badRequest.Value.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOptimizedLineage_EmptyDigest_ReturnsBadRequest()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage("");
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOptimizedLineage_NotFound_ReturnsNotFound()
|
||||
{
|
||||
// Arrange - no graph setup
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage("sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadata_WithValidDigest_ReturnsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:meta123";
|
||||
_graphService.SetupGraph(digest, new LineageGraphResponse(
|
||||
new LineageGraphDto(
|
||||
Nodes: ImmutableArray.Create(
|
||||
new LineageNodeDto(digest, "app", "1.0.0", 10)),
|
||||
Edges: ImmutableArray<LineageEdgeDto>.Empty),
|
||||
Enrichment: null));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetMetadata(digest);
|
||||
|
||||
// Assert
|
||||
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var metadata = okResult.Value.Should().BeOfType<LineageGraphMetadataDto>().Subject;
|
||||
metadata.CenterDigest.Should().Be(digest);
|
||||
metadata.TotalNodes.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadata_NotFound_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetMetadata("sha256:missing");
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateCache_ReturnsNoContent()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.InvalidateCache("sha256:abc123");
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOptimizedLineage_WithSearchTerm_FiltersNodes()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:center";
|
||||
_graphService.SetupGraph(digest, new LineageGraphResponse(
|
||||
new LineageGraphDto(
|
||||
Nodes: ImmutableArray.Create(
|
||||
new LineageNodeDto(digest, "center-app", "1.0.0", 10),
|
||||
new LineageNodeDto("sha256:logging", "logging-lib", "1.0.0", 5),
|
||||
new LineageNodeDto("sha256:database", "database-lib", "1.0.0", 8)),
|
||||
Edges: ImmutableArray.Create(
|
||||
new LineageEdgeDto(digest, "sha256:logging"),
|
||||
new LineageEdgeDto(digest, "sha256:database"))),
|
||||
Enrichment: null));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage(digest, searchTerm: "log");
|
||||
|
||||
// Assert
|
||||
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var graph = okResult.Value.Should().BeOfType<OptimizedLineageGraphDto>().Subject;
|
||||
// The optimizer filters, so we verify it was called with the search term
|
||||
_optimizer.LastRequest.Should().NotBeNull();
|
||||
_optimizer.LastRequest!.SearchTerm.Should().Be("log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOptimizedLineage_WithPagination_ReturnsPagedResults()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:center";
|
||||
var nodes = new List<LineageNodeDto>
|
||||
{
|
||||
new(digest, "center", "1.0.0", 10)
|
||||
};
|
||||
var edges = new List<LineageEdgeDto>();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var childDigest = $"sha256:child{i:D2}";
|
||||
nodes.Add(new LineageNodeDto(childDigest, $"child-{i}", "1.0.0", i + 1));
|
||||
edges.Add(new LineageEdgeDto(digest, childDigest));
|
||||
}
|
||||
|
||||
_graphService.SetupGraph(digest, new LineageGraphResponse(
|
||||
new LineageGraphDto(
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray()),
|
||||
Enrichment: null));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage(digest, pageSize: 5, pageNumber: 0);
|
||||
|
||||
// Assert
|
||||
var okResult = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||
var graph = okResult.Value.Should().BeOfType<OptimizedLineageGraphDto>().Subject;
|
||||
graph.PageSize.Should().Be(5);
|
||||
graph.PageNumber.Should().Be(0);
|
||||
}
|
||||
|
||||
// Test helper implementations
|
||||
private sealed class InMemoryLineageStreamService : ILineageStreamService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryLineageStreamService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
public async IAsyncEnumerable<LineageUpdateEvent> SubscribeAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<string>? watchDigests = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task PublishAsync(Guid tenantId, LineageUpdateEvent evt, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifySbomAddedAsync(Guid tenantId, string artifactDigest, string? parentDigest,
|
||||
SbomVersionSummary summary, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifyVexChangedAsync(Guid tenantId, string artifactDigest, VexChangeData change,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifyReachabilityUpdatedAsync(Guid tenantId, string artifactDigest, ReachabilityUpdateData update,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task NotifyEdgeChangedAsync(Guid tenantId, string fromDigest, string toDigest,
|
||||
LineageEdgeChangeType changeType, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryLineageGraphOptimizer : ILineageGraphOptimizer
|
||||
{
|
||||
public LineageOptimizationRequest? LastRequest { get; private set; }
|
||||
|
||||
public OptimizedLineageGraph Optimize(LineageOptimizationRequest request)
|
||||
{
|
||||
LastRequest = request;
|
||||
return new OptimizedLineageGraph
|
||||
{
|
||||
Nodes = request.AllNodes,
|
||||
Edges = request.AllEdges,
|
||||
BoundaryNodes = ImmutableArray<BoundaryNodeInfo>.Empty,
|
||||
TotalNodes = request.AllNodes.Length,
|
||||
HasMorePages = false
|
||||
};
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<LineageLevel> TraverseLevelsAsync(
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
TraversalDirection direction,
|
||||
int maxDepth = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield return new LineageLevel(0, nodes, true);
|
||||
}
|
||||
|
||||
public Task<LineageGraphMetadata> GetOrComputeMetadataAsync(
|
||||
Guid tenantId,
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new LineageGraphMetadata(
|
||||
TotalNodes: nodes.Length,
|
||||
TotalEdges: edges.Length,
|
||||
MaxDepth: 1,
|
||||
ComputedAt: DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
public Task InvalidateCacheAsync(Guid tenantId, string centerDigest, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryLineageGraphService : ILineageGraphService
|
||||
{
|
||||
private readonly Dictionary<string, LineageGraphResponse> _graphs = new();
|
||||
|
||||
public void SetupGraph(string digest, LineageGraphResponse response)
|
||||
{
|
||||
_graphs[digest] = response;
|
||||
}
|
||||
|
||||
public ValueTask<LineageGraphResponse> GetLineageAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
LineageQueryOptions options,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_graphs.TryGetValue(artifactDigest, out var response))
|
||||
return ValueTask.FromResult(response);
|
||||
|
||||
return ValueTask.FromResult(new LineageGraphResponse(
|
||||
new LineageGraphDto(ImmutableArray<LineageNodeDto>.Empty, ImmutableArray<LineageEdgeDto>.Empty),
|
||||
null));
|
||||
}
|
||||
|
||||
public ValueTask<LineageDiffResponse> GetDiffAsync(
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return ValueTask.FromResult(new LineageDiffResponse(
|
||||
ImmutableArray<LineageChangeSummary>.Empty,
|
||||
ImmutableArray<LineageChangeSummary>.Empty,
|
||||
ImmutableArray<LineageChangeSummary>.Empty));
|
||||
}
|
||||
|
||||
public ValueTask<ExportResult> ExportEvidencePackAsync(
|
||||
ExportRequest request,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return ValueTask.FromResult(new ExportResult("https://example.com/pack.zip", 1024));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder types to match interface expectations
|
||||
file record LineageNodeDto(string Digest, string Name, string Version, int ComponentCount);
|
||||
file record LineageEdgeDto(string FromDigest, string ToDigest);
|
||||
file record LineageGraphDto(ImmutableArray<LineageNodeDto> Nodes, ImmutableArray<LineageEdgeDto> Edges);
|
||||
file record LineageGraphResponse(LineageGraphDto Graph, object? Enrichment);
|
||||
file record LineageDiffResponse(
|
||||
ImmutableArray<LineageChangeSummary> Added,
|
||||
ImmutableArray<LineageChangeSummary> Removed,
|
||||
ImmutableArray<LineageChangeSummary> Modified);
|
||||
file record LineageChangeSummary(string Digest, string Name);
|
||||
file record ExportRequest(string ArtifactDigest, int MaxDepth);
|
||||
file record ExportResult(string DownloadUrl, long SizeBytes);
|
||||
file record LineageQueryOptions(int MaxDepth, bool IncludeVerdicts, bool IncludeBadges);
|
||||
Reference in New Issue
Block a user