save checkpoint
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
// 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.Domain;
|
||||
using StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Controllers;
|
||||
@@ -59,7 +59,7 @@ public sealed class LineageStreamController : ControllerBase
|
||||
|
||||
var digestList = string.IsNullOrWhiteSpace(watchDigests)
|
||||
? null
|
||||
: watchDigests.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
: (IReadOnlyList<string>)watchDigests.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -67,7 +67,6 @@ public sealed class LineageStreamController : ControllerBase
|
||||
{
|
||||
var eventData = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
id = evt.EventId,
|
||||
type = evt.EventType.ToString(),
|
||||
digest = evt.AffectedDigest,
|
||||
parentDigest = evt.ParentDigest,
|
||||
@@ -139,52 +138,40 @@ public sealed class LineageStreamController : ControllerBase
|
||||
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,
|
||||
Offset = pageNumber * pageSize,
|
||||
Limit = pageSize,
|
||||
SearchTerm = searchTerm
|
||||
};
|
||||
|
||||
var optimized = _optimizer.Optimize(request);
|
||||
var optimized = _optimizer.Optimize(fullResult.Graph, 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
|
||||
Digest = n.ArtifactDigest,
|
||||
Name = n.Metadata?.ImageReference ?? n.ArtifactDigest,
|
||||
Version = n.Metadata?.Tag ?? string.Empty,
|
||||
ComponentCount = 0
|
||||
}).ToList(),
|
||||
Edges = optimized.Edges.Select(e => new LineageEdgeDto
|
||||
{
|
||||
FromDigest = e.FromDigest,
|
||||
ToDigest = e.ToDigest
|
||||
FromDigest = e.ParentDigest,
|
||||
ToDigest = e.ChildDigest
|
||||
}).ToList(),
|
||||
BoundaryNodes = optimized.BoundaryNodes.Select(b => new BoundaryNodeDto
|
||||
{
|
||||
Digest = b.Digest,
|
||||
HiddenChildrenCount = b.HiddenChildrenCount,
|
||||
HiddenParentsCount = b.HiddenParentsCount
|
||||
HiddenChildrenCount = b.HiddenChildCount,
|
||||
HiddenParentsCount = b.HiddenParentCount
|
||||
}).ToList(),
|
||||
TotalNodes = optimized.TotalNodes,
|
||||
HasMorePages = optimized.HasMorePages,
|
||||
TotalNodes = optimized.TotalNodeCount,
|
||||
HasMorePages = optimized.HasMore,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize
|
||||
});
|
||||
@@ -219,7 +206,7 @@ public sealed class LineageStreamController : ControllerBase
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<TraversalDirection>(direction, ignoreCase: true, out var traversalDir))
|
||||
if (!Enum.TryParse<TraversalDirection>(direction, ignoreCase: true, out _))
|
||||
{
|
||||
Response.StatusCode = 400;
|
||||
await Response.WriteAsync("Invalid direction. Use: Children, Parents, or Center");
|
||||
@@ -243,28 +230,41 @@ public sealed class LineageStreamController : ControllerBase
|
||||
return;
|
||||
}
|
||||
|
||||
var allNodes = fullResult.Graph.Nodes
|
||||
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
|
||||
.ToImmutableArray();
|
||||
// Build lookup maps from the full graph for the callback functions
|
||||
var nodesByDigest = fullResult.Graph.Nodes.ToDictionary(n => n.ArtifactDigest, StringComparer.Ordinal);
|
||||
var childrenByDigest = fullResult.Graph.Edges
|
||||
.GroupBy(e => e.ParentDigest, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<LineageNode>)g
|
||||
.Select(e => nodesByDigest.GetValueOrDefault(e.ChildDigest))
|
||||
.Where(n => n is not null)
|
||||
.ToList()!,
|
||||
StringComparer.Ordinal);
|
||||
var parentsByDigest = fullResult.Graph.Edges
|
||||
.GroupBy(e => e.ChildDigest, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<LineageNode>)g
|
||||
.Select(e => nodesByDigest.GetValueOrDefault(e.ParentDigest))
|
||||
.Where(n => n is not null)
|
||||
.ToList()!,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var allEdges = fullResult.Graph.Edges
|
||||
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
|
||||
.ToImmutableArray();
|
||||
Task<IReadOnlyList<LineageNode>> GetChildren(string digest, CancellationToken token) =>
|
||||
Task.FromResult(childrenByDigest.GetValueOrDefault(digest, (IReadOnlyList<LineageNode>)Array.Empty<LineageNode>()));
|
||||
|
||||
Task<IReadOnlyList<LineageNode>> GetParents(string digest, CancellationToken token) =>
|
||||
Task.FromResult(parentsByDigest.GetValueOrDefault(digest, (IReadOnlyList<LineageNode>)Array.Empty<LineageNode>()));
|
||||
|
||||
await foreach (var level in _optimizer.TraverseLevelsAsync(
|
||||
artifactDigest, allNodes, allEdges, traversalDir, maxDepth, ct))
|
||||
artifactDigest, GetChildren, GetParents, 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
|
||||
depth = level.Level,
|
||||
direction = level.Direction.ToString(),
|
||||
nodeDigests = level.NodeDigests
|
||||
});
|
||||
|
||||
await Response.WriteAsync($"event: level\n", ct);
|
||||
@@ -315,16 +315,22 @@ public sealed class LineageStreamController : ControllerBase
|
||||
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);
|
||||
artifactDigest,
|
||||
tenantId,
|
||||
async innerCt =>
|
||||
{
|
||||
// Compute metadata from the full graph
|
||||
return new LineageGraphMetadata
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
TotalNodes = fullResult.Graph.Nodes.Count,
|
||||
TotalEdges = fullResult.Graph.Edges.Count,
|
||||
MaxDepth = ComputeMaxDepth(fullResult.Graph, artifactDigest),
|
||||
LastUpdated = DateTimeOffset.UtcNow
|
||||
};
|
||||
},
|
||||
ct);
|
||||
|
||||
return Ok(new LineageGraphMetadataDto
|
||||
{
|
||||
@@ -332,7 +338,7 @@ public sealed class LineageStreamController : ControllerBase
|
||||
TotalNodes = metadata.TotalNodes,
|
||||
TotalEdges = metadata.TotalEdges,
|
||||
MaxDepth = metadata.MaxDepth,
|
||||
ComputedAt = metadata.ComputedAt
|
||||
ComputedAt = metadata.LastUpdated
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -362,7 +368,7 @@ public sealed class LineageStreamController : ControllerBase
|
||||
if (tenantId == Guid.Empty)
|
||||
return Unauthorized();
|
||||
|
||||
await _optimizer.InvalidateCacheAsync(tenantId, artifactDigest, ct);
|
||||
await _optimizer.InvalidateCacheAsync(artifactDigest, tenantId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -371,6 +377,35 @@ public sealed class LineageStreamController : ControllerBase
|
||||
// TODO: Extract from claims or headers
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
|
||||
private static int ComputeMaxDepth(LineageGraph graph, string centerDigest)
|
||||
{
|
||||
if (graph.Nodes.Count == 0)
|
||||
return 0;
|
||||
|
||||
var childrenMap = graph.Edges
|
||||
.GroupBy(e => e.ParentDigest)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.ChildDigest).ToList());
|
||||
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var maxDepth = 0;
|
||||
|
||||
void Dfs(string digest, int depth)
|
||||
{
|
||||
if (!visited.Add(digest))
|
||||
return;
|
||||
if (depth > maxDepth)
|
||||
maxDepth = depth;
|
||||
if (childrenMap.TryGetValue(digest, out var children))
|
||||
{
|
||||
foreach (var child in children)
|
||||
Dfs(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
Dfs(centerDigest, 0);
|
||||
return maxDepth;
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs for API responses
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.SbomService.Lineage.Domain;
|
||||
|
||||
namespace StellaOps.SbomService.Lineage.Services;
|
||||
|
||||
@@ -14,43 +14,34 @@ 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);
|
||||
OptimizedLineageGraph Optimize(
|
||||
LineageGraph fullGraph,
|
||||
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,
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<LineageNode>>> getChildrenAsync,
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<LineageNode>>> getParentsAsync,
|
||||
int maxDepth = 5,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get or compute cached metadata about a lineage graph.
|
||||
/// </summary>
|
||||
Task<LineageGraphMetadata> GetOrComputeMetadataAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
Func<CancellationToken, Task<LineageGraphMetadata>> computeAsync,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidate cached metadata for an artifact.
|
||||
/// </summary>
|
||||
Task InvalidateCacheAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
string centerDigest,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ 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,
|
||||
@@ -24,12 +20,12 @@ public interface ILineageStreamService : IDisposable
|
||||
/// <summary>
|
||||
/// Publish an update event to all relevant subscribers.
|
||||
/// </summary>
|
||||
Task PublishAsync(Guid tenantId, LineageUpdateEvent evt, CancellationToken ct = default);
|
||||
ValueTask PublishAsync(LineageUpdateEvent updateEvent, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Notify subscribers about a new SBOM version.
|
||||
/// </summary>
|
||||
Task NotifySbomAddedAsync(
|
||||
ValueTask NotifySbomAddedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
string? parentDigest,
|
||||
@@ -39,7 +35,7 @@ public interface ILineageStreamService : IDisposable
|
||||
/// <summary>
|
||||
/// Notify subscribers about a VEX status change.
|
||||
/// </summary>
|
||||
Task NotifyVexChangedAsync(
|
||||
ValueTask NotifyVexChangedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
VexChangeData change,
|
||||
@@ -48,7 +44,7 @@ public interface ILineageStreamService : IDisposable
|
||||
/// <summary>
|
||||
/// Notify subscribers about reachability updates.
|
||||
/// </summary>
|
||||
Task NotifyReachabilityUpdatedAsync(
|
||||
ValueTask NotifyReachabilityUpdatedAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
ReachabilityUpdateData update,
|
||||
@@ -57,7 +53,7 @@ public interface ILineageStreamService : IDisposable
|
||||
/// <summary>
|
||||
/// Notify subscribers about lineage edge changes.
|
||||
/// </summary>
|
||||
Task NotifyEdgeChangedAsync(
|
||||
ValueTask NotifyEdgeChangedAsync(
|
||||
Guid tenantId,
|
||||
string fromDigest,
|
||||
string toDigest,
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed class LineageGraphOptimizer : ILineageGraphOptimizer
|
||||
// 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))
|
||||
.Where(e => visibleDigests.Contains(e.ParentDigest) || visibleDigests.Contains(e.ChildDigest))
|
||||
.ToList();
|
||||
|
||||
// 5. Compute boundary nodes (nodes with edges outside visible set)
|
||||
@@ -102,7 +102,6 @@ public sealed class LineageGraphOptimizer : ILineageGraphOptimizer
|
||||
{
|
||||
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
|
||||
@@ -260,19 +259,19 @@ public sealed class LineageGraphOptimizer : ILineageGraphOptimizer
|
||||
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!adjacency.TryGetValue(edge.FromDigest, out var fromList))
|
||||
if (!adjacency.TryGetValue(edge.ParentDigest, out var fromList))
|
||||
{
|
||||
fromList = new List<string>();
|
||||
adjacency[edge.FromDigest] = fromList;
|
||||
adjacency[edge.ParentDigest] = fromList;
|
||||
}
|
||||
fromList.Add(edge.ToDigest);
|
||||
fromList.Add(edge.ChildDigest);
|
||||
|
||||
if (!adjacency.TryGetValue(edge.ToDigest, out var toList))
|
||||
if (!adjacency.TryGetValue(edge.ChildDigest, out var toList))
|
||||
{
|
||||
toList = new List<string>();
|
||||
adjacency[edge.ToDigest] = toList;
|
||||
adjacency[edge.ChildDigest] = toList;
|
||||
}
|
||||
toList.Add(edge.FromDigest);
|
||||
toList.Add(edge.ParentDigest);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
@@ -303,9 +302,10 @@ public sealed class LineageGraphOptimizer : ILineageGraphOptimizer
|
||||
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)
|
||||
n.Metadata?.ImageReference?.Contains(term, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
n.Metadata?.Repository?.Contains(term, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
n.Metadata?.Tag?.Contains(term, StringComparison.OrdinalIgnoreCase) == true)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -319,11 +319,11 @@ public sealed class LineageGraphOptimizer : ILineageGraphOptimizer
|
||||
foreach (var node in visibleNodes)
|
||||
{
|
||||
var hiddenChildren = allEdges
|
||||
.Where(e => e.FromDigest == node.ArtifactDigest && !visibleDigests.Contains(e.ToDigest))
|
||||
.Where(e => e.ParentDigest == node.ArtifactDigest && !visibleDigests.Contains(e.ChildDigest))
|
||||
.Count();
|
||||
|
||||
var hiddenParents = allEdges
|
||||
.Where(e => e.ToDigest == node.ArtifactDigest && !visibleDigests.Contains(e.FromDigest))
|
||||
.Where(e => e.ChildDigest == node.ArtifactDigest && !visibleDigests.Contains(e.ParentDigest))
|
||||
.Count();
|
||||
|
||||
if (hiddenChildren > 0 || hiddenParents > 0)
|
||||
|
||||
@@ -46,14 +46,9 @@ public sealed class LineageStreamControllerTests
|
||||
{
|
||||
// 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));
|
||||
_graphService.SetupGraph(digest, CreateGraphResponse(
|
||||
new[] { CreateNode(digest), CreateNode("sha256:child") },
|
||||
new[] { CreateEdge(digest, "sha256:child") }));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage(digest, maxDepth: 3, pageSize: 50, pageNumber: 0);
|
||||
@@ -103,12 +98,9 @@ public sealed class LineageStreamControllerTests
|
||||
{
|
||||
// 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));
|
||||
_graphService.SetupGraph(digest, CreateGraphResponse(
|
||||
new[] { CreateNode(digest) },
|
||||
Array.Empty<LineageEdge>()));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetMetadata(digest);
|
||||
@@ -145,16 +137,18 @@ public sealed class LineageStreamControllerTests
|
||||
{
|
||||
// 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));
|
||||
_graphService.SetupGraph(digest, CreateGraphResponse(
|
||||
new[]
|
||||
{
|
||||
CreateNode(digest),
|
||||
CreateNode("sha256:logging"),
|
||||
CreateNode("sha256:database")
|
||||
},
|
||||
new[]
|
||||
{
|
||||
CreateEdge(digest, "sha256:logging"),
|
||||
CreateEdge(digest, "sha256:database")
|
||||
}));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage(digest, searchTerm: "log");
|
||||
@@ -172,24 +166,17 @@ public sealed class LineageStreamControllerTests
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:center";
|
||||
var nodes = new List<LineageNodeDto>
|
||||
{
|
||||
new(digest, "center", "1.0.0", 10)
|
||||
};
|
||||
var edges = new List<LineageEdgeDto>();
|
||||
var nodes = new List<LineageNode> { CreateNode(digest) };
|
||||
var edges = new List<LineageEdge>();
|
||||
|
||||
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));
|
||||
nodes.Add(CreateNode(childDigest));
|
||||
edges.Add(CreateEdge(digest, childDigest));
|
||||
}
|
||||
|
||||
_graphService.SetupGraph(digest, new LineageGraphResponse(
|
||||
new LineageGraphDto(
|
||||
Nodes: nodes.ToImmutableArray(),
|
||||
Edges: edges.ToImmutableArray()),
|
||||
Enrichment: null));
|
||||
_graphService.SetupGraph(digest, CreateGraphResponse(nodes, edges));
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetOptimizedLineage(digest, pageSize: 5, pageNumber: 0);
|
||||
@@ -201,6 +188,19 @@ public sealed class LineageStreamControllerTests
|
||||
graph.PageNumber.Should().Be(0);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static LineageNode CreateNode(string artifactDigest) =>
|
||||
new(artifactDigest, Guid.NewGuid(), 1, DateTimeOffset.UtcNow, null);
|
||||
|
||||
private static LineageEdge CreateEdge(string parent, string child) =>
|
||||
new(Guid.NewGuid(), parent, child, LineageRelationship.Parent, Guid.Empty, DateTimeOffset.UtcNow);
|
||||
|
||||
private static LineageGraphResponse CreateGraphResponse(
|
||||
IEnumerable<LineageNode> nodes,
|
||||
IEnumerable<LineageEdge> edges) =>
|
||||
new(new LineageGraph(nodes.ToList(), edges.ToList()),
|
||||
new Dictionary<string, NodeEnrichment>());
|
||||
|
||||
// Test helper implementations
|
||||
private sealed class InMemoryLineageStreamService : ILineageStreamService
|
||||
{
|
||||
@@ -216,76 +216,80 @@ public sealed class LineageStreamControllerTests
|
||||
public async IAsyncEnumerable<LineageUpdateEvent> SubscribeAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyList<string>? watchDigests = null,
|
||||
CancellationToken ct = default)
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task PublishAsync(Guid tenantId, LineageUpdateEvent evt, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
public ValueTask PublishAsync(LineageUpdateEvent updateEvent, CancellationToken ct = default)
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public Task NotifySbomAddedAsync(Guid tenantId, string artifactDigest, string? parentDigest,
|
||||
public ValueTask NotifySbomAddedAsync(Guid tenantId, string artifactDigest, string? parentDigest,
|
||||
SbomVersionSummary summary, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public Task NotifyVexChangedAsync(Guid tenantId, string artifactDigest, VexChangeData change,
|
||||
public ValueTask NotifyVexChangedAsync(Guid tenantId, string artifactDigest, VexChangeData change,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public Task NotifyReachabilityUpdatedAsync(Guid tenantId, string artifactDigest, ReachabilityUpdateData update,
|
||||
public ValueTask NotifyReachabilityUpdatedAsync(Guid tenantId, string artifactDigest, ReachabilityUpdateData update,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
=> ValueTask.CompletedTask;
|
||||
|
||||
public Task NotifyEdgeChangedAsync(Guid tenantId, string fromDigest, string toDigest,
|
||||
public ValueTask NotifyEdgeChangedAsync(Guid tenantId, string fromDigest, string toDigest,
|
||||
LineageEdgeChangeType changeType, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class InMemoryLineageGraphOptimizer : ILineageGraphOptimizer
|
||||
{
|
||||
public LineageOptimizationRequest? LastRequest { get; private set; }
|
||||
|
||||
public OptimizedLineageGraph Optimize(LineageOptimizationRequest request)
|
||||
public OptimizedLineageGraph Optimize(
|
||||
LineageGraph fullGraph,
|
||||
LineageOptimizationRequest request)
|
||||
{
|
||||
LastRequest = request;
|
||||
return new OptimizedLineageGraph
|
||||
{
|
||||
Nodes = request.AllNodes,
|
||||
Edges = request.AllEdges,
|
||||
Nodes = fullGraph.Nodes,
|
||||
Edges = fullGraph.Edges,
|
||||
BoundaryNodes = ImmutableArray<BoundaryNodeInfo>.Empty,
|
||||
TotalNodes = request.AllNodes.Length,
|
||||
HasMorePages = false
|
||||
TotalNodeCount = fullGraph.Nodes.Count,
|
||||
HasMore = false,
|
||||
Offset = request.Offset,
|
||||
Limit = request.Limit,
|
||||
OptimizationTimeMs = 0
|
||||
};
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<LineageLevel> TraverseLevelsAsync(
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
TraversalDirection direction,
|
||||
int maxDepth = 10,
|
||||
CancellationToken ct = default)
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<LineageNode>>> getChildrenAsync,
|
||||
Func<string, CancellationToken, Task<IReadOnlyList<LineageNode>>> getParentsAsync,
|
||||
int maxDepth = 5,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield return new LineageLevel(0, nodes, true);
|
||||
yield return new LineageLevel
|
||||
{
|
||||
Level = 0,
|
||||
Direction = TraversalDirection.Center,
|
||||
NodeDigests = ImmutableArray.Create(centerDigest)
|
||||
};
|
||||
}
|
||||
|
||||
public Task<LineageGraphMetadata> GetOrComputeMetadataAsync(
|
||||
string artifactDigest,
|
||||
Guid tenantId,
|
||||
string centerDigest,
|
||||
ImmutableArray<LineageNode> nodes,
|
||||
ImmutableArray<LineageEdge> edges,
|
||||
Func<CancellationToken, Task<LineageGraphMetadata>> computeAsync,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(new LineageGraphMetadata(
|
||||
TotalNodes: nodes.Length,
|
||||
TotalEdges: edges.Length,
|
||||
MaxDepth: 1,
|
||||
ComputedAt: DateTimeOffset.UtcNow));
|
||||
return computeAsync(ct);
|
||||
}
|
||||
|
||||
public Task InvalidateCacheAsync(Guid tenantId, string centerDigest, CancellationToken ct = default)
|
||||
public Task InvalidateCacheAsync(string artifactDigest, Guid tenantId, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -308,8 +312,10 @@ public sealed class LineageStreamControllerTests
|
||||
return ValueTask.FromResult(response);
|
||||
|
||||
return ValueTask.FromResult(new LineageGraphResponse(
|
||||
new LineageGraphDto(ImmutableArray<LineageNodeDto>.Empty, ImmutableArray<LineageEdgeDto>.Empty),
|
||||
null));
|
||||
new LineageGraph(
|
||||
ImmutableArray<LineageNode>.Empty,
|
||||
ImmutableArray<LineageEdge>.Empty),
|
||||
new Dictionary<string, NodeEnrichment>()));
|
||||
}
|
||||
|
||||
public ValueTask<LineageDiffResponse> GetDiffAsync(
|
||||
@@ -319,9 +325,15 @@ public sealed class LineageStreamControllerTests
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return ValueTask.FromResult(new LineageDiffResponse(
|
||||
ImmutableArray<LineageChangeSummary>.Empty,
|
||||
ImmutableArray<LineageChangeSummary>.Empty,
|
||||
ImmutableArray<LineageChangeSummary>.Empty));
|
||||
fromDigest,
|
||||
toDigest,
|
||||
new SbomDiff(
|
||||
ImmutableArray<ComponentChange>.Empty,
|
||||
ImmutableArray<ComponentChange>.Empty,
|
||||
ImmutableArray<ComponentChange>.Empty),
|
||||
new VexDiff(
|
||||
ImmutableArray<VexDelta>.Empty, 0, 0, 0, 0),
|
||||
null));
|
||||
}
|
||||
|
||||
public ValueTask<ExportResult> ExportEvidencePackAsync(
|
||||
@@ -329,21 +341,11 @@ public sealed class LineageStreamControllerTests
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return ValueTask.FromResult(new ExportResult("https://example.com/pack.zip", 1024));
|
||||
return ValueTask.FromResult(new ExportResult(
|
||||
"https://example.com/pack.zip",
|
||||
DateTimeOffset.UtcNow.AddHours(1),
|
||||
1024,
|
||||
null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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