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:
master
2026-04-06 08:52:37 +03:00
parent 1cff9ef9cc
commit 285f761c77
14 changed files with 1467 additions and 14 deletions

View File

@@ -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