Files
git.stella-ops.org/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs
master 285f761c77 Add Graph saved views persistence and compatibility endpoints
Introduce PostgresGraphSavedViewStore with SQL migration, in-memory fallback,
CompatibilityEndpoints for UI contract alignment, and integration tests
with a shared Postgres fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:52:37 +03:00

479 lines
20 KiB
C#

using StellaOps.Graph.Api.Contracts;
namespace StellaOps.Graph.Api.Services;
public sealed class InMemoryGraphRepository
{
private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"];
private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
private readonly List<NodeTile> _nodes;
private readonly List<EdgeTile> _edges;
private readonly Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> _snapshots;
public InMemoryGraphRepository(IEnumerable<NodeTile>? seed = null, IEnumerable<EdgeTile>? edges = null)
{
_nodes = seed?.ToList() ?? new List<NodeTile>
{
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "example", ["version"] = "1.0.0" } },
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } },
new() { Id = "gn:acme:artifact:sha256:abc", Kind = "artifact", Tenant = "acme", Attributes = new() { ["digest"] = "sha256:abc", ["ecosystem"] = "container", ["displayName"] = "auth-service", ["version"] = "2026.04.04" } },
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", ["displayName"] = "gamma", ["version"] = "3.1.4" } },
new() { Id = "gn:acme:vuln:CVE-2026-1234", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-1234", ["severity"] = "critical", ["cveId"] = "CVE-2026-1234" } },
new() { Id = "gn:acme:vuln:CVE-2026-5678", Kind = "vuln", Tenant = "acme", Attributes = new() { ["displayName"] = "CVE-2026-5678", ["severity"] = "high", ["cveId"] = "CVE-2026-5678" } },
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm", ["displayName"] = "widget", ["version"] = "2.0.0" } },
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo", Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container", ["displayName"] = "transform-service", ["version"] = "2026.04.04" } },
new() { Id = "gn:bravo:vuln:CVE-2026-9001", Kind = "vuln", Tenant = "bravo", Attributes = new() { ["displayName"] = "CVE-2026-9001", ["severity"] = "medium", ["cveId"] = "CVE-2026-9001" } },
};
_edges = edges?.ToList() ?? new List<EdgeTile>
{
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:component->component:gamma", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:component:gamma", Attributes = new() { ["scope"] = "runtime" } },
new() { Id = "ge:acme:component->vuln:widget", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:widget", Target = "gn:acme:vuln:CVE-2026-1234", Attributes = new() { ["scope"] = "runtime" } },
new() { Id = "ge:acme:component->vuln:gamma", Kind = "affects", Tenant = "acme", Source = "gn:acme:component:gamma", Target = "gn:acme:vuln:CVE-2026-5678", 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" } },
new() { Id = "ge:bravo:component->vuln:widget", Kind = "affects", Tenant = "bravo", Source = "gn:bravo:component:widget", Target = "gn:bravo:vuln:CVE-2026-9001", Attributes = new() { ["scope"] = "runtime" } },
};
// Drop edges whose endpoints aren't present in the current node set to avoid invalid graph seeds in tests.
var nodeIds = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
_edges = _edges.Where(e => nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target)).ToList();
_snapshots = SeedSnapshots();
}
public IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request)
{
var queryable = _nodes
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
.Where(n => request.Kinds is null || request.Kinds.Length == 0 || request.Kinds.Contains(n.Kind, StringComparer.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(request.Query))
{
queryable = queryable.Where(n => MatchesQuery(n, request.Query!));
}
if (request.Filters is not null)
{
queryable = queryable.Where(n => FiltersMatch(n, request.Filters!));
}
return queryable;
}
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request)
{
var nodes = Query(tenant, new GraphSearchRequest
{
Kinds = request.Kinds,
Query = request.Query,
Filters = request.Filters,
Limit = request.Limit,
Cursor = request.Cursor
}).ToList();
var nodeIds = nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
var edges = request.IncludeEdges
? _edges.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal) && nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target))
.OrderBy(e => e.Id, StringComparer.Ordinal)
.ToList()
: new List<EdgeTile>();
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))
{
return (snap.Nodes, snap.Edges);
}
return null;
}
public IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant)
=> _nodes
.Where(node => node.Tenant.Equals(tenant, StringComparison.Ordinal))
.Where(node => CompatibilityKinds.Contains(node.Kind, StringComparer.OrdinalIgnoreCase))
.OrderBy(node => node.Id, StringComparer.Ordinal)
.ToList();
public IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant)
{
var nodeIds = GetCompatibilityNodes(tenant).Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
return _edges
.Where(edge => edge.Tenant.Equals(tenant, StringComparison.Ordinal))
.Where(edge => nodeIds.Contains(edge.Source) && nodeIds.Contains(edge.Target))
.OrderBy(edge => edge.Id, StringComparer.Ordinal)
.ToList();
}
public static string BuildGraphId(string tenant)
=> $"graph::{tenant}::main";
public bool GraphExists(string tenant, string graphId)
=> string.Equals(BuildGraphId(tenant), graphId, StringComparison.OrdinalIgnoreCase);
public NodeTile? GetNode(string tenant, string nodeId)
=> _nodes.FirstOrDefault(node =>
node.Tenant.Equals(tenant, StringComparison.Ordinal)
&& node.Id.Equals(nodeId, StringComparison.Ordinal));
public (IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId)
{
var edges = GetCompatibilityEdges(tenant);
var incoming = edges.Where(edge => edge.Target.Equals(nodeId, StringComparison.Ordinal)).ToList();
var outgoing = edges.Where(edge => edge.Source.Equals(nodeId, StringComparison.Ordinal)).ToList();
return (incoming, outgoing);
}
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth)
{
var nodes = GetCompatibilityNodes(tenant).ToDictionary(node => node.Id, StringComparer.Ordinal);
if (!nodes.ContainsKey(sourceId) || !nodes.ContainsKey(targetId))
{
return null;
}
var edges = GetCompatibilityEdges(tenant);
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
foreach (var edge in edges)
{
if (!adjacency.TryGetValue(edge.Source, out var list))
{
list = new List<EdgeTile>();
adjacency[edge.Source] = list;
}
list.Add(edge);
}
var queue = new Queue<(string NodeId, List<EdgeTile> PathEdges)>();
var visited = new HashSet<string>(StringComparer.Ordinal) { sourceId };
queue.Enqueue((sourceId, new List<EdgeTile>()));
while (queue.Count > 0)
{
var (nodeId, pathEdges) = queue.Dequeue();
if (nodeId.Equals(targetId, StringComparison.Ordinal))
{
var pathNodes = BuildNodeListFromEdges(nodes, sourceId, pathEdges);
return (pathNodes, pathEdges);
}
if (pathEdges.Count >= maxDepth || !adjacency.TryGetValue(nodeId, out var outgoing))
{
continue;
}
foreach (var edge in outgoing.OrderBy(edge => edge.Id, StringComparer.Ordinal))
{
if (!visited.Add(edge.Target))
{
continue;
}
var nextEdges = new List<EdgeTile>(pathEdges.Count + 1);
nextEdges.AddRange(pathEdges);
nextEdges.Add(edge);
queue.Enqueue((edge.Target, nextEdges));
}
}
return null;
}
public DateTimeOffset GetSnapshotTimestamp()
=> FixedSnapshotAt;
private Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> SeedSnapshots()
{
var dict = new Dictionary<string, (List<NodeTile>, List<EdgeTile>)>(StringComparer.Ordinal);
dict["acme:snapA"] = (new List<NodeTile>(_nodes), new List<EdgeTile>(_edges));
var updatedNodes = new List<NodeTile>(_nodes.Select(n => n with
{
Attributes = new Dictionary<string, object?>(n.Attributes)
}));
var widget = updatedNodes.FirstOrDefault(n => n.Id == "gn:acme:component:widget");
if (widget is null)
{
// Custom seeds may not include the default widget node; skip optional snapshot wiring in that case.
return dict;
}
widget.Attributes["purl"] = "pkg:npm/widget@2.1.0";
widget.Attributes["version"] = "2.1.0";
updatedNodes.Add(new NodeTile
{
Id = "gn:acme:component:newlib",
Kind = "component",
Tenant = "acme",
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "newlib", ["version"] = "1.0.0" }
});
var updatedEdges = new List<EdgeTile>(_edges)
{
new()
{
Id = "ge:acme:component->component:new",
Kind = "depends_on",
Tenant = "acme",
Source = widget.Id,
Target = "gn:acme:component:newlib",
Attributes = new() { ["scope"] = "runtime" }
}
};
dict["acme:snapB"] = (updatedNodes, updatedEdges);
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();
return node.Id.ToLowerInvariant().Contains(q)
|| node.Attributes.Values.OfType<string>().Any(v => v.Contains(q, StringComparison.OrdinalIgnoreCase));
}
private static bool FiltersMatch(NodeTile node, IReadOnlyDictionary<string, object> filters)
{
foreach (var kvp in filters)
{
if (!node.Attributes.TryGetValue(kvp.Key, out var value))
{
return false;
}
if (kvp.Value is null)
{
continue;
}
if (!kvp.Value.ToString()!.Equals(value?.ToString(), StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
private static IReadOnlyList<NodeTile> BuildNodeListFromEdges(
IDictionary<string, NodeTile> nodes,
string sourceId,
IReadOnlyList<EdgeTile> edges)
{
var list = new List<NodeTile>();
if (nodes.TryGetValue(sourceId, out var firstNode))
{
list.Add(firstNode);
}
foreach (var edge in edges)
{
if (nodes.TryGetValue(edge.Target, out var node))
{
list.Add(node);
}
}
return list;
}
}
internal static class CursorCodec
{
public static string Encode(int offset) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(offset.ToString()));
public static int Decode(string? token)
{
if (string.IsNullOrWhiteSpace(token)) return 0;
try
{
var text = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(token));
return int.TryParse(text, out var value) ? value : 0;
}
catch
{
return 0;
}
}
}