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>
This commit is contained in:
@@ -4,6 +4,8 @@ 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;
|
||||
@@ -12,23 +14,30 @@ public sealed class InMemoryGraphRepository
|
||||
{
|
||||
_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" } },
|
||||
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" } },
|
||||
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.
|
||||
@@ -195,6 +204,101 @@ public sealed class InMemoryGraphRepository
|
||||
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);
|
||||
@@ -214,13 +318,14 @@ public sealed class InMemoryGraphRepository
|
||||
}
|
||||
|
||||
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" }
|
||||
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm", ["displayName"] = "newlib", ["version"] = "1.0.0" }
|
||||
});
|
||||
|
||||
var updatedEdges = new List<EdgeTile>(_edges)
|
||||
@@ -329,6 +434,28 @@ public sealed class InMemoryGraphRepository
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user