Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
45
src/Graph/StellaOps.Graph.Api/Contracts/LineageContracts.cs
Normal file
45
src/Graph/StellaOps.Graph.Api/Contracts/LineageContracts.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Graph.Api.Contracts;
|
||||
|
||||
public sealed record GraphLineageRequest
|
||||
{
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("maxDepth")]
|
||||
public int? MaxDepth { get; init; }
|
||||
|
||||
[JsonPropertyName("relationshipKinds")]
|
||||
public string[]? RelationshipKinds { get; init; }
|
||||
}
|
||||
|
||||
public sealed record GraphLineageResponse
|
||||
{
|
||||
[JsonPropertyName("nodes")]
|
||||
public IReadOnlyList<NodeTile> Nodes { get; init; } = Array.Empty<NodeTile>();
|
||||
|
||||
[JsonPropertyName("edges")]
|
||||
public IReadOnlyList<EdgeTile> Edges { get; init; } = Array.Empty<EdgeTile>();
|
||||
}
|
||||
|
||||
public static class LineageValidator
|
||||
{
|
||||
public static string? Validate(GraphLineageRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest) && string.IsNullOrWhiteSpace(request.SbomDigest))
|
||||
{
|
||||
return "artifactDigest or sbomDigest is required";
|
||||
}
|
||||
|
||||
if (request.MaxDepth.HasValue && (request.MaxDepth.Value < 1 || request.MaxDepth.Value > 6))
|
||||
{
|
||||
return "maxDepth must be between 1 and 6";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
||||
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
||||
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
||||
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
|
||||
builder.Services.AddScoped<IGraphLineageService, InMemoryGraphLineageService>();
|
||||
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
|
||||
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
|
||||
@@ -238,6 +239,53 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.MapPost("/graph/lineage", async (HttpContext context, GraphLineageRequest request, IGraphLineageService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/lineage"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/lineage", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var validation = LineageValidator.Validate(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
|
||||
LogAudit(context, "/graph/lineage", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var tenantId = tenant!;
|
||||
var response = await service.GetLineageAsync(tenantId, request, ct);
|
||||
LogAudit(context, "/graph/lineage", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public interface IGraphLineageService
|
||||
{
|
||||
Task<GraphLineageResponse> GetLineageAsync(string tenant, GraphLineageRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphLineageService : IGraphLineageService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
|
||||
public InMemoryGraphLineageService(InMemoryGraphRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public Task<GraphLineageResponse> GetLineageAsync(string tenant, GraphLineageRequest request, CancellationToken ct)
|
||||
{
|
||||
var (nodes, edges) = _repository.GetLineage(tenant, request);
|
||||
return Task.FromResult(new GraphLineageResponse { Nodes = nodes, Edges = edges });
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ public sealed class InMemoryGraphRepository
|
||||
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm" } },
|
||||
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
|
||||
new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container" } },
|
||||
new() { Id = "gn:acme:sbom:sha256:sbom-a", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-a", ["artifact_digest"] = "sha256:abc", ["format"] = "cyclonedx" } },
|
||||
new() { Id = "gn:acme:sbom:sha256:sbom-b", Kind = "sbom", Tenant = "acme", Attributes = new() { ["sbom_digest"] = "sha256:sbom-b", ["artifact_digest"] = "sha256:abc", ["format"] = "spdx" } },
|
||||
new() { Id = "gn:acme:component:gamma", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:nuget/Gamma@3.1.4", ["ecosystem"] = "nuget" } },
|
||||
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo",Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
|
||||
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo",Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container" } },
|
||||
@@ -24,6 +26,8 @@ public sealed class InMemoryGraphRepository
|
||||
{
|
||||
new() { Id = "ge:acme:artifact->component", Kind = "builds", Tenant = "acme", Source = "gn:acme:artifact:sha256:abc", Target = "gn:acme:component:example", Attributes = new() { ["reason"] = "sbom" } },
|
||||
new() { Id = "ge:acme:component->component", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:example", Target = "gn:acme:component:widget", Attributes = new() { ["scope"] = "runtime" } },
|
||||
new() { Id = "ge:acme:sbom->artifact", Kind = "SBOM_VERSION_OF", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:artifact:sha256:abc", Attributes = new() { ["relationship"] = "version_of" } },
|
||||
new() { Id = "ge:acme:sbom->sbom", Kind = "SBOM_LINEAGE_PARENT", Tenant = "acme", Source = "gn:acme:sbom:sha256:sbom-b", Target = "gn:acme:sbom:sha256:sbom-a", Attributes = new() { ["relationship"] = "parent" } },
|
||||
new() { Id = "ge:bravo:artifact->component", Kind = "builds", Tenant = "bravo", Source = "gn:bravo:artifact:sha256:def", Target = "gn:bravo:component:widget", Attributes = new() { ["reason"] = "sbom" } },
|
||||
};
|
||||
|
||||
@@ -74,6 +78,114 @@ public sealed class InMemoryGraphRepository
|
||||
return (nodes, edges);
|
||||
}
|
||||
|
||||
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var maxDepth = request.MaxDepth ?? 3;
|
||||
if (maxDepth < 1)
|
||||
{
|
||||
maxDepth = 1;
|
||||
}
|
||||
|
||||
var allowedKinds = BuildLineageKindFilter(request.RelationshipKinds);
|
||||
var tenantNodes = _nodes
|
||||
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (tenantNodes.Count == 0)
|
||||
{
|
||||
return (Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
|
||||
}
|
||||
|
||||
var nodeById = tenantNodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var seedIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
var digest = request.ArtifactDigest.Trim();
|
||||
foreach (var node in tenantNodes.Where(n => HasAttribute(n, "artifact_digest", digest)))
|
||||
{
|
||||
seedIds.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SbomDigest))
|
||||
{
|
||||
var digest = request.SbomDigest.Trim();
|
||||
foreach (var node in tenantNodes.Where(n => HasAttribute(n, "sbom_digest", digest)))
|
||||
{
|
||||
seedIds.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (seedIds.Count == 0)
|
||||
{
|
||||
return (Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
|
||||
}
|
||||
|
||||
var tenantEdges = _edges
|
||||
.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal))
|
||||
.Where(e => IsLineageEdgeAllowed(e, allowedKinds))
|
||||
.ToList();
|
||||
|
||||
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
|
||||
foreach (var edge in tenantEdges)
|
||||
{
|
||||
AddAdjacency(adjacency, edge.Source, edge);
|
||||
AddAdjacency(adjacency, edge.Target, edge);
|
||||
}
|
||||
|
||||
var visitedNodes = new HashSet<string>(seedIds, StringComparer.Ordinal);
|
||||
var visitedEdges = new HashSet<string>(StringComparer.Ordinal);
|
||||
var frontier = new HashSet<string>(seedIds, StringComparer.Ordinal);
|
||||
|
||||
for (var depth = 0; depth < maxDepth && frontier.Count > 0; depth++)
|
||||
{
|
||||
var next = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var nodeId in frontier)
|
||||
{
|
||||
if (!adjacency.TryGetValue(nodeId, out var edges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (visitedEdges.Add(edge.Id))
|
||||
{
|
||||
var other = string.Equals(edge.Source, nodeId, StringComparison.Ordinal)
|
||||
? edge.Target
|
||||
: edge.Source;
|
||||
if (visitedNodes.Add(other))
|
||||
{
|
||||
next.Add(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
var resultNodes = new List<NodeTile>();
|
||||
foreach (var nodeId in visitedNodes.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
if (nodeById.TryGetValue(nodeId, out var node))
|
||||
{
|
||||
resultNodes.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
var resultEdges = tenantEdges
|
||||
.Where(edge => visitedEdges.Contains(edge.Id))
|
||||
.Where(edge => visitedNodes.Contains(edge.Source) && visitedNodes.Contains(edge.Target))
|
||||
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return (resultNodes, resultEdges);
|
||||
}
|
||||
|
||||
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
|
||||
{
|
||||
if (_snapshots.TryGetValue($"{tenant}:{snapshotId}", out var snap))
|
||||
@@ -128,6 +240,69 @@ public sealed class InMemoryGraphRepository
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static bool HasAttribute(NodeTile node, string key, string expected)
|
||||
{
|
||||
if (!node.Attributes.TryGetValue(key, out var value) || value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(value.ToString(), expected, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HashSet<string> BuildLineageKindFilter(string[]? relationshipKinds)
|
||||
{
|
||||
if (relationshipKinds is null || relationshipKinds.Length == 0)
|
||||
{
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in relationshipKinds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
set.Add(value.Trim());
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
private static bool IsLineageEdgeAllowed(EdgeTile edge, HashSet<string> allowedKinds)
|
||||
{
|
||||
if (allowedKinds.Count == 0)
|
||||
{
|
||||
return edge.Kind.StartsWith("SBOM_LINEAGE_", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(edge.Kind, "SBOM_VERSION_OF", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (allowedKinds.Contains(edge.Kind))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (edge.Attributes.TryGetValue("relationship", out var relationship) && relationship is not null)
|
||||
{
|
||||
return allowedKinds.Contains(relationship.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddAdjacency(Dictionary<string, List<EdgeTile>> adjacency, string nodeId, EdgeTile edge)
|
||||
{
|
||||
if (!adjacency.TryGetValue(nodeId, out var list))
|
||||
{
|
||||
list = new List<EdgeTile>();
|
||||
adjacency[nodeId] = list;
|
||||
}
|
||||
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
private static bool MatchesQuery(NodeTile node, string query)
|
||||
{
|
||||
var q = query.ToLowerInvariant();
|
||||
|
||||
@@ -10,6 +10,9 @@ public sealed class SbomIngestTransformer
|
||||
private const string DependsOnEdgeKind = "DEPENDS_ON";
|
||||
private const string DeclaredInEdgeKind = "DECLARED_IN";
|
||||
private const string BuiltFromEdgeKind = "BUILT_FROM";
|
||||
private const string SbomNodeKind = "sbom";
|
||||
private const string SbomVersionOfEdgeKind = "SBOM_VERSION_OF";
|
||||
private const string SbomLineageEdgePrefix = "SBOM_LINEAGE_";
|
||||
|
||||
public GraphBuildBatch Transform(SbomSnapshot snapshot)
|
||||
{
|
||||
@@ -19,6 +22,7 @@ public sealed class SbomIngestTransformer
|
||||
var edges = new List<JsonObject>();
|
||||
|
||||
var artifactNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
|
||||
var sbomNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
|
||||
var componentNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
|
||||
var fileNodes = new Dictionary<string, JsonObject>(StringComparer.OrdinalIgnoreCase);
|
||||
var licenseCandidates = new Dictionary<(string License, string SourceDigest), LicenseCandidate>(LicenseKeyComparer.Instance);
|
||||
@@ -30,6 +34,16 @@ public sealed class SbomIngestTransformer
|
||||
nodes.Add(artifactNode);
|
||||
artifactNodes[GetArtifactKey(snapshot.ArtifactDigest, snapshot.SbomDigest)] = artifactNode;
|
||||
|
||||
var sbomNode = CreateSbomNode(snapshot);
|
||||
if (sbomNode is not null)
|
||||
{
|
||||
nodes.Add(sbomNode);
|
||||
sbomNodes[snapshot.SbomDigest] = sbomNode;
|
||||
|
||||
var sbomEdge = CreateSbomVersionOfEdge(snapshot, sbomNode, artifactNode, NextEdgeOffset());
|
||||
edges.Add(sbomEdge);
|
||||
}
|
||||
|
||||
foreach (var component in snapshot.Components)
|
||||
{
|
||||
var componentNode = CreateComponentNode(snapshot, component);
|
||||
@@ -91,6 +105,27 @@ public sealed class SbomIngestTransformer
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
if (sbomNode is not null && snapshot.Lineage.Count > 0)
|
||||
{
|
||||
foreach (var lineage in snapshot.Lineage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lineage.SbomDigest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sbomNodes.TryGetValue(lineage.SbomDigest, out var relatedNode))
|
||||
{
|
||||
relatedNode = CreateLineageSbomNode(snapshot, lineage);
|
||||
nodes.Add(relatedNode);
|
||||
sbomNodes[lineage.SbomDigest] = relatedNode;
|
||||
}
|
||||
|
||||
var edge = CreateLineageEdge(snapshot, sbomNode, relatedNode, lineage, NextEdgeOffset());
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
var orderedNodes = nodes
|
||||
.OrderBy(node => node["kind"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
.ThenBy(node => node["id"]!.GetValue<string>(), StringComparer.Ordinal)
|
||||
@@ -168,6 +203,76 @@ public sealed class SbomIngestTransformer
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private static JsonObject? CreateSbomNode(SbomSnapshot snapshot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshot.SbomDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["sbom_digest"] = snapshot.SbomDigest,
|
||||
["artifact_digest"] = snapshot.ArtifactDigest,
|
||||
["format"] = snapshot.SbomFormat,
|
||||
["format_version"] = snapshot.SbomFormatVersion
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.SbomVersionId))
|
||||
{
|
||||
attributes["version_id"] = snapshot.SbomVersionId;
|
||||
}
|
||||
|
||||
if (snapshot.SbomSequence > 0)
|
||||
{
|
||||
attributes["sequence"] = snapshot.SbomSequence;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.ChainId))
|
||||
{
|
||||
attributes["chain_id"] = snapshot.ChainId;
|
||||
}
|
||||
|
||||
return GraphDocumentFactory.CreateNode(new GraphNodeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: SbomNodeKind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["sbom_digest"] = snapshot.SbomDigest
|
||||
},
|
||||
Attributes: attributes,
|
||||
Provenance: new GraphProvenanceSpec(snapshot.Source, snapshot.CollectedAt, snapshot.SbomDigest, snapshot.EventOffset + 1),
|
||||
ValidFrom: snapshot.CollectedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private static JsonObject CreateLineageSbomNode(SbomSnapshot snapshot, SbomLineageReference lineage)
|
||||
{
|
||||
var attributes = new JsonObject
|
||||
{
|
||||
["sbom_digest"] = lineage.SbomDigest,
|
||||
["artifact_digest"] = lineage.ArtifactDigest
|
||||
};
|
||||
|
||||
return GraphDocumentFactory.CreateNode(new GraphNodeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: SbomNodeKind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["sbom_digest"] = lineage.SbomDigest
|
||||
},
|
||||
Attributes: attributes,
|
||||
Provenance: new GraphProvenanceSpec(
|
||||
ResolveSource(lineage.Source, snapshot.Source),
|
||||
lineage.CollectedAt,
|
||||
lineage.SbomDigest,
|
||||
lineage.EventOffset),
|
||||
ValidFrom: lineage.CollectedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private static JsonObject CreateComponentNode(SbomSnapshot snapshot, SbomComponent component)
|
||||
{
|
||||
var attributes = new JsonObject
|
||||
@@ -199,6 +304,59 @@ public sealed class SbomIngestTransformer
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private static JsonObject CreateSbomVersionOfEdge(SbomSnapshot snapshot, JsonObject sbomNode, JsonObject artifactNode, long eventOffset)
|
||||
{
|
||||
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: SbomVersionOfEdgeKind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["sbom_node_id"] = sbomNode["id"]!.GetValue<string>(),
|
||||
["artifact_node_id"] = artifactNode["id"]!.GetValue<string>()
|
||||
},
|
||||
Attributes: new JsonObject
|
||||
{
|
||||
["sbom_digest"] = snapshot.SbomDigest,
|
||||
["artifact_digest"] = snapshot.ArtifactDigest,
|
||||
["chain_id"] = snapshot.ChainId,
|
||||
["sequence"] = snapshot.SbomSequence
|
||||
},
|
||||
Provenance: new GraphProvenanceSpec(snapshot.Source, snapshot.CollectedAt, snapshot.SbomDigest, eventOffset),
|
||||
ValidFrom: snapshot.CollectedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private static JsonObject CreateLineageEdge(SbomSnapshot snapshot, JsonObject fromNode, JsonObject toNode, SbomLineageReference lineage, long fallbackOffset)
|
||||
{
|
||||
var offset = lineage.EventOffset > 0 ? lineage.EventOffset : fallbackOffset;
|
||||
var kind = NormalizeLineageKind(lineage.Relationship);
|
||||
|
||||
return GraphDocumentFactory.CreateEdge(new GraphEdgeSpec(
|
||||
Tenant: snapshot.Tenant,
|
||||
Kind: kind,
|
||||
CanonicalKey: new Dictionary<string, string>
|
||||
{
|
||||
["tenant"] = snapshot.Tenant,
|
||||
["from_sbom_node_id"] = fromNode["id"]!.GetValue<string>(),
|
||||
["to_sbom_node_id"] = toNode["id"]!.GetValue<string>(),
|
||||
["relationship"] = lineage.Relationship
|
||||
},
|
||||
Attributes: new JsonObject
|
||||
{
|
||||
["relationship"] = lineage.Relationship,
|
||||
["sbom_digest"] = lineage.SbomDigest,
|
||||
["artifact_digest"] = lineage.ArtifactDigest
|
||||
},
|
||||
Provenance: new GraphProvenanceSpec(
|
||||
ResolveSource(lineage.Source, snapshot.Source),
|
||||
lineage.CollectedAt,
|
||||
snapshot.SbomDigest,
|
||||
offset),
|
||||
ValidFrom: lineage.CollectedAt,
|
||||
ValidTo: null));
|
||||
}
|
||||
|
||||
private static JsonObject CreateFileNode(SbomSnapshot snapshot, SbomComponentFile file)
|
||||
{
|
||||
var attributes = new JsonObject
|
||||
@@ -390,6 +548,17 @@ public sealed class SbomIngestTransformer
|
||||
return array;
|
||||
}
|
||||
|
||||
private static string NormalizeLineageKind(string relationship)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relationship))
|
||||
{
|
||||
return SbomLineageEdgePrefix + "UNKNOWN";
|
||||
}
|
||||
|
||||
var normalized = relationship.Trim().Replace('-', '_').Replace(' ', '_').ToUpperInvariant();
|
||||
return SbomLineageEdgePrefix + normalized;
|
||||
}
|
||||
|
||||
private static LicenseCandidate CreateLicenseCandidate(SbomSnapshot snapshot, SbomComponent component)
|
||||
{
|
||||
var collectedAt = component.CollectedAt.AddSeconds(2);
|
||||
|
||||
@@ -16,6 +16,21 @@ public sealed class SbomSnapshot
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomVersionId")]
|
||||
public string SbomVersionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomSequence")]
|
||||
public int SbomSequence { get; init; }
|
||||
|
||||
[JsonPropertyName("sbomFormat")]
|
||||
public string SbomFormat { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomFormatVersion")]
|
||||
public string SbomFormatVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("chainId")]
|
||||
public string ChainId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
@@ -33,6 +48,9 @@ public sealed class SbomSnapshot
|
||||
|
||||
[JsonPropertyName("baseArtifacts")]
|
||||
public IReadOnlyList<SbomBaseArtifact> BaseArtifacts { get; init; } = Array.Empty<SbomBaseArtifact>();
|
||||
|
||||
[JsonPropertyName("lineage")]
|
||||
public IReadOnlyList<SbomLineageReference> Lineage { get; init; } = Array.Empty<SbomLineageReference>();
|
||||
}
|
||||
|
||||
public sealed class SbomArtifactMetadata
|
||||
@@ -229,3 +247,24 @@ public sealed class SbomBaseArtifact
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SbomLineageReference
|
||||
{
|
||||
[JsonPropertyName("relationship")]
|
||||
public string Relationship { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("artifactDigest")]
|
||||
public string ArtifactDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventOffset")]
|
||||
public long EventOffset { get; init; }
|
||||
|
||||
[JsonPropertyName("collectedAt")]
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class LineageServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetLineageAsync_ReturnsSbomAndArtifactChain()
|
||||
{
|
||||
var repository = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphLineageService(repository);
|
||||
|
||||
var request = new GraphLineageRequest
|
||||
{
|
||||
SbomDigest = "sha256:sbom-b",
|
||||
MaxDepth = 3
|
||||
};
|
||||
|
||||
var response = await service.GetLineageAsync("acme", request, CancellationToken.None);
|
||||
|
||||
Assert.Contains(response.Nodes, node => node.Id == "gn:acme:sbom:sha256:sbom-b");
|
||||
Assert.Contains(response.Nodes, node => node.Id == "gn:acme:sbom:sha256:sbom-a");
|
||||
Assert.Contains(response.Nodes, node => node.Id == "gn:acme:artifact:sha256:abc");
|
||||
Assert.Contains(response.Edges, edge => edge.Kind == "SBOM_LINEAGE_PARENT");
|
||||
Assert.Contains(response.Edges, edge => edge.Kind == "SBOM_VERSION_OF");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomLineageTransformerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Transform_adds_lineage_edges_when_present()
|
||||
{
|
||||
var snapshot = new SbomSnapshot
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
ArtifactDigest = "sha256:artifact",
|
||||
SbomDigest = "sha256:sbom",
|
||||
SbomFormat = "cyclonedx",
|
||||
SbomFormatVersion = "1.6",
|
||||
Lineage = new[]
|
||||
{
|
||||
new SbomLineageReference
|
||||
{
|
||||
Relationship = "parent",
|
||||
SbomDigest = "sha256:parent",
|
||||
ArtifactDigest = "sha256:parent-artifact",
|
||||
CollectedAt = DateTimeOffset.Parse("2025-12-01T00:00:00Z")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var transformer = new SbomIngestTransformer();
|
||||
var batch = transformer.Transform(snapshot);
|
||||
|
||||
Assert.Contains(batch.Nodes, n => n["kind"]!.GetValue<string>() == "sbom");
|
||||
|
||||
var edgeKinds = batch.Edges
|
||||
.Select(e => e["kind"]!.GetValue<string>())
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("SBOM_VERSION_OF", edgeKinds);
|
||||
Assert.Contains("SBOM_LINEAGE_PARENT", edgeKinds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user