feat(graph): add Postgres graph runtime repository + compatibility endpoints
Introduces IGraphRuntimeRepository + PostgresGraphRuntimeRepository that back runtime-path graph reads with real persistence. Graph.Api Program.cs wires the new repository into the DI graph. InMemory* services get small cleanups so they remain viable for tests and local dev. CompatibilityEndpoints: extends the integration-test surface. Tests: GraphPostgresRuntimeIntegrationTests, GraphRuntimeRepositoryRegistrationTests, expanded GraphCompatibilityEndpointsIntegrationTests. Docs: graph architecture page updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@
|
|||||||
- `POST /graph/diff` — compares `snapshotA` vs `snapshotB`, streaming node/edge added/removed/changed tiles plus stats; budget enforcement mirrors `/graph/query`.
|
- `POST /graph/diff` — compares `snapshotA` vs `snapshotB`, streaming node/edge added/removed/changed tiles plus stats; budget enforcement mirrors `/graph/query`.
|
||||||
- `POST /graph/export` — async job producing deterministic manifests (`sha256`, size, format) for `ndjson/csv/graphml/png/svg`; download via `/graph/export/{jobId}`.
|
- `POST /graph/export` — async job producing deterministic manifests (`sha256`, size, format) for `ndjson/csv/graphml/png/svg`; download via `/graph/export/{jobId}`.
|
||||||
- `POST /graph/lineage` - returns SBOM lineage nodes/edges anchored by `artifactDigest` or `sbomDigest`, with optional relationship filters and depth limits.
|
- `POST /graph/lineage` - returns SBOM lineage nodes/edges anchored by `artifactDigest` or `sbomDigest`, with optional relationship filters and depth limits.
|
||||||
|
- Runtime repository selection is config-driven at service resolution time: when `Postgres:Graph` is configured, the live `/graph/query`, `/graph/diff`, and shipped `/graphs*` compatibility surfaces materialize persisted rows from `graph.graph_nodes`, `graph.graph_edges`, and `graph.pending_snapshots`; when it is not configured, the runtime fallback is an empty in-memory repository rather than historical demo-seeded graph data.
|
||||||
- Compatibility facade for the shipped Angular explorer:
|
- Compatibility facade for the shipped Angular explorer:
|
||||||
- `GET /graphs`, `GET /graphs/{graphId}`, `GET /graphs/{graphId}/tiles`
|
- `GET /graphs`, `GET /graphs/{graphId}`, `GET /graphs/{graphId}/tiles`
|
||||||
- `GET /search`, `GET /paths`
|
- `GET /search`, `GET /paths`
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public static class CompatibilityEndpoints
|
|||||||
{
|
{
|
||||||
app.MapGet("/graphs", async Task<IResult> (
|
app.MapGet("/graphs", async Task<IResult> (
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||||
@@ -26,7 +26,7 @@ public static class CompatibilityEndpoints
|
|||||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||||
var graphId = InMemoryGraphRepository.BuildGraphId(tenantId);
|
var graphId = InMemoryGraphRepository.BuildGraphId(tenantId);
|
||||||
var snapshotAt = repository.GetSnapshotTimestamp();
|
var snapshotAt = repository.GetSnapshotTimestamp(tenantId);
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
@@ -55,7 +55,7 @@ public static class CompatibilityEndpoints
|
|||||||
app.MapGet("/graphs/{graphId}", async Task<IResult> (
|
app.MapGet("/graphs/{graphId}", async Task<IResult> (
|
||||||
string graphId,
|
string graphId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||||
@@ -72,7 +72,7 @@ public static class CompatibilityEndpoints
|
|||||||
|
|
||||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||||
var snapshotAt = repository.GetSnapshotTimestamp();
|
var snapshotAt = repository.GetSnapshotTimestamp(tenantId);
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
@@ -95,7 +95,7 @@ public static class CompatibilityEndpoints
|
|||||||
string graphId,
|
string graphId,
|
||||||
bool? includeOverlays,
|
bool? includeOverlays,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
IOverlayService overlayService,
|
IOverlayService overlayService,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -152,7 +152,7 @@ public static class CompatibilityEndpoints
|
|||||||
string? graphId,
|
string? graphId,
|
||||||
int? pageSize,
|
int? pageSize,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||||
@@ -211,7 +211,7 @@ public static class CompatibilityEndpoints
|
|||||||
int? maxDepth,
|
int? maxDepth,
|
||||||
string? graphId,
|
string? graphId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
|
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
|
||||||
@@ -257,7 +257,7 @@ public static class CompatibilityEndpoints
|
|||||||
string graphId,
|
string graphId,
|
||||||
string? format,
|
string? format,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
IGraphExportService exportService,
|
IGraphExportService exportService,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -295,7 +295,7 @@ public static class CompatibilityEndpoints
|
|||||||
app.MapGet("/assets/{assetId}/snapshot", async Task<IResult> (
|
app.MapGet("/assets/{assetId}/snapshot", async Task<IResult> (
|
||||||
string assetId,
|
string assetId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||||
@@ -331,7 +331,7 @@ public static class CompatibilityEndpoints
|
|||||||
kind = NormalizeNodeKind(asset.Kind),
|
kind = NormalizeNodeKind(asset.Kind),
|
||||||
components = componentIds,
|
components = componentIds,
|
||||||
vulnerabilities,
|
vulnerabilities,
|
||||||
snapshotAt = repository.GetSnapshotTimestamp()
|
snapshotAt = repository.GetSnapshotTimestamp(tenantId)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.RequireTenant();
|
.RequireTenant();
|
||||||
@@ -340,7 +340,7 @@ public static class CompatibilityEndpoints
|
|||||||
string nodeId,
|
string nodeId,
|
||||||
string? graphId,
|
string? graphId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||||
@@ -381,7 +381,7 @@ public static class CompatibilityEndpoints
|
|||||||
app.MapGet("/graphs/{graphId}/saved-views", async Task<IResult> (
|
app.MapGet("/graphs/{graphId}/saved-views", async Task<IResult> (
|
||||||
string graphId,
|
string graphId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
IGraphSavedViewStore store,
|
IGraphSavedViewStore store,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -405,7 +405,7 @@ public static class CompatibilityEndpoints
|
|||||||
string graphId,
|
string graphId,
|
||||||
CreateGraphSavedViewRequest request,
|
CreateGraphSavedViewRequest request,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
IGraphSavedViewStore store,
|
IGraphSavedViewStore store,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -439,7 +439,7 @@ public static class CompatibilityEndpoints
|
|||||||
string graphId,
|
string graphId,
|
||||||
string viewId,
|
string viewId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
IGraphSavedViewStore store,
|
IGraphSavedViewStore store,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ using static StellaOps.Localization.T;
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
// When Postgres is configured, start with an empty repository (data loaded from DB by hosted service).
|
builder.Services.AddSingleton<IGraphRuntimeRepository>(sp =>
|
||||||
// When no Postgres, fall back to hardcoded seed data for demo/development.
|
{
|
||||||
var hasPostgres = !string.IsNullOrWhiteSpace(ResolveGraphConnectionString(builder.Configuration));
|
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
|
||||||
builder.Services.AddSingleton(_ => hasPostgres
|
if (string.IsNullOrWhiteSpace(options.ConnectionString))
|
||||||
? new InMemoryGraphRepository(seed: Array.Empty<NodeTile>(), edges: Array.Empty<EdgeTile>())
|
{
|
||||||
: new InMemoryGraphRepository());
|
return new InMemoryGraphRepository(seed: Array.Empty<NodeTile>(), edges: Array.Empty<EdgeTile>());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActivatorUtilities.CreateInstance<PostgresGraphRuntimeRepository>(sp);
|
||||||
|
});
|
||||||
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
||||||
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
||||||
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
||||||
@@ -84,12 +88,6 @@ builder.Services.AddSingleton<PostgresGraphRepository>(sp =>
|
|||||||
return null!;
|
return null!;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// When Postgres is configured, run a hosted service that loads graph data from the DB
|
|
||||||
// into the InMemoryGraphRepository on startup and refreshes it periodically.
|
|
||||||
if (hasPostgres)
|
|
||||||
{
|
|
||||||
builder.Services.AddHostedService<GraphDataLoaderHostedService>();
|
|
||||||
}
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(options =>
|
.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using StellaOps.Graph.Api.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Graph.Api.Services;
|
||||||
|
|
||||||
|
public interface IGraphRuntimeRepository
|
||||||
|
{
|
||||||
|
IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request);
|
||||||
|
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request);
|
||||||
|
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request);
|
||||||
|
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId);
|
||||||
|
IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant);
|
||||||
|
IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant);
|
||||||
|
bool GraphExists(string tenant, string graphId);
|
||||||
|
NodeTile? GetNode(string tenant, string nodeId);
|
||||||
|
(IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId);
|
||||||
|
(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth);
|
||||||
|
DateTimeOffset GetSnapshotTimestamp(string tenant);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
private readonly ILogger<InMemoryEdgeMetadataService> _logger;
|
private readonly ILogger<InMemoryEdgeMetadataService> _logger;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
|||||||
private readonly Dictionary<string, EdgeExplanationPayload> _explanations;
|
private readonly Dictionary<string, EdgeExplanationPayload> _explanations;
|
||||||
|
|
||||||
public InMemoryEdgeMetadataService(
|
public InMemoryEdgeMetadataService(
|
||||||
InMemoryGraphRepository repository,
|
IGraphRuntimeRepository repository,
|
||||||
ILogger<InMemoryEdgeMetadataService> logger,
|
ILogger<InMemoryEdgeMetadataService> logger,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
@@ -31,8 +31,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
|||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
|
||||||
// Seed with default explanations for demo/test data
|
_explanations = new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal);
|
||||||
_explanations = SeedDefaultExplanations();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
|
public Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
|
||||||
@@ -121,11 +120,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
|||||||
|
|
||||||
foreach (var edge in allEdges)
|
foreach (var edge in allEdges)
|
||||||
{
|
{
|
||||||
if (!_explanations.TryGetValue(edge.Id, out var explanation))
|
var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (explanation.Reason == reason)
|
if (explanation.Reason == reason)
|
||||||
{
|
{
|
||||||
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
|
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
|
||||||
@@ -159,11 +154,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
|||||||
|
|
||||||
foreach (var edge in allEdges)
|
foreach (var edge in allEdges)
|
||||||
{
|
{
|
||||||
if (!_explanations.TryGetValue(edge.Id, out var explanation))
|
var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (explanation.Evidence is not null &&
|
if (explanation.Evidence is not null &&
|
||||||
explanation.Evidence.TryGetValue(evidenceType, out var refValue) &&
|
explanation.Evidence.TryGetValue(evidenceType, out var refValue) &&
|
||||||
string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase))
|
string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -239,6 +230,13 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
|||||||
ConfidenceBps = 10000
|
ConfidenceBps = 10000
|
||||||
},
|
},
|
||||||
Summary = summary,
|
Summary = summary,
|
||||||
|
Provenance = new EdgeProvenanceRef
|
||||||
|
{
|
||||||
|
Source = "graph-indexer",
|
||||||
|
CollectedAt = now,
|
||||||
|
SbomDigest = TryResolveEvidence(edge, "sbom", "sbom_digest")
|
||||||
|
},
|
||||||
|
Evidence = BuildEvidence(edge),
|
||||||
Tags = [edge.Kind]
|
Tags = [edge.Kind]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -335,64 +333,35 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
|||||||
return Array.Empty<EdgeTile>();
|
return Array.Empty<EdgeTile>();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Dictionary<string, EdgeExplanationPayload> SeedDefaultExplanations()
|
private static Dictionary<string, string>? BuildEvidence(EdgeTile edge)
|
||||||
{
|
{
|
||||||
var now = _timeProvider.GetUtcNow();
|
var evidence = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
AddEvidence(evidence, "sbom", edge, "sbom_digest");
|
||||||
|
AddEvidence(evidence, "artifact", edge, "artifact_digest");
|
||||||
|
AddEvidence(evidence, "advisory", edge, "advisory_id");
|
||||||
|
AddEvidence(evidence, "vex", edge, "statement_id");
|
||||||
|
return evidence.Count == 0 ? null : evidence;
|
||||||
|
}
|
||||||
|
|
||||||
return new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal)
|
private static void AddEvidence(Dictionary<string, string> evidence, string evidenceType, EdgeTile edge, string attributeName)
|
||||||
|
{
|
||||||
|
var value = TryResolveEvidence(edge, evidenceType, attributeName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
["ge:acme:artifact->component"] = EdgeExplanationFactory.FromSbomDependency(
|
evidence[evidenceType] = value;
|
||||||
"sha256:sbom-a",
|
}
|
||||||
"sbom-parser",
|
}
|
||||||
now.AddHours(-1),
|
|
||||||
"Build artifact produces component"),
|
|
||||||
|
|
||||||
["ge:acme:component->component"] = new EdgeExplanationPayload
|
private static string? TryResolveEvidence(EdgeTile edge, string evidenceType, string attributeName)
|
||||||
{
|
{
|
||||||
Reason = EdgeReason.SbomDependency,
|
if (edge.Attributes.TryGetValue(attributeName, out var attributeValue) && attributeValue is not null)
|
||||||
Via = new EdgeVia
|
{
|
||||||
{
|
return attributeValue.ToString();
|
||||||
Method = "sbom-parser",
|
}
|
||||||
Timestamp = now.AddHours(-1),
|
|
||||||
ConfidenceBps = 10000,
|
|
||||||
EvidenceRef = "sha256:sbom-a"
|
|
||||||
},
|
|
||||||
Summary = "example@1.0.0 depends on widget@2.0.0 for runtime",
|
|
||||||
Evidence = new Dictionary<string, string> { ["sbom"] = "sha256:sbom-a" },
|
|
||||||
Provenance = new EdgeProvenanceRef
|
|
||||||
{
|
|
||||||
Source = "sbom-parser",
|
|
||||||
CollectedAt = now.AddHours(-1),
|
|
||||||
SbomDigest = "sha256:sbom-a"
|
|
||||||
},
|
|
||||||
Tags = ["runtime", "dependency"]
|
|
||||||
},
|
|
||||||
|
|
||||||
["ge:acme:sbom->artifact"] = new EdgeExplanationPayload
|
return edge.Attributes.TryGetValue(evidenceType, out var directValue) && directValue is not null
|
||||||
{
|
? directValue.ToString()
|
||||||
Reason = EdgeReason.SbomDependency,
|
: null;
|
||||||
Via = new EdgeVia
|
|
||||||
{
|
|
||||||
Method = "sbom-linker",
|
|
||||||
Timestamp = now.AddHours(-2),
|
|
||||||
ConfidenceBps = 10000
|
|
||||||
},
|
|
||||||
Summary = "SBOM describes artifact sha256:abc"
|
|
||||||
},
|
|
||||||
|
|
||||||
["ge:acme:sbom->sbom"] = new EdgeExplanationPayload
|
|
||||||
{
|
|
||||||
Reason = EdgeReason.Provenance,
|
|
||||||
Via = new EdgeVia
|
|
||||||
{
|
|
||||||
Method = "lineage-tracker",
|
|
||||||
Timestamp = now.AddDays(-1),
|
|
||||||
ConfidenceBps = 10000
|
|
||||||
},
|
|
||||||
Summary = "SBOM lineage: sbom-b derives from sbom-a",
|
|
||||||
Tags = ["lineage", "provenance"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +373,7 @@ public static class InMemoryGraphRepositoryExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a single edge by ID.
|
/// Gets a single edge by ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static EdgeTile? GetEdge(this InMemoryGraphRepository repository, string tenant, string edgeId)
|
public static EdgeTile? GetEdge(this IGraphRuntimeRepository repository, string tenant, string edgeId)
|
||||||
{
|
{
|
||||||
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
|
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
|
||||||
{
|
{
|
||||||
@@ -419,7 +388,7 @@ public static class InMemoryGraphRepositoryExtensions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all edges for a tenant.
|
/// Gets all edges for a tenant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<EdgeTile> GetAllEdges(this InMemoryGraphRepository repository, string tenant)
|
public static IReadOnlyList<EdgeTile> GetAllEdges(this IGraphRuntimeRepository repository, string tenant)
|
||||||
{
|
{
|
||||||
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
|
var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
|
|
||||||
public sealed class InMemoryGraphDiffService : IGraphDiffService
|
public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
private readonly IGraphMetrics _metrics;
|
private readonly IGraphMetrics _metrics;
|
||||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||||
{
|
{
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
public InMemoryGraphDiffService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
public InMemoryGraphDiffService(IGraphRuntimeRepository repository, IGraphMetrics metrics)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
|
|
||||||
public sealed class InMemoryGraphExportService : IGraphExportService
|
public sealed class InMemoryGraphExportService : IGraphExportService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
private readonly IGraphMetrics _metrics;
|
private readonly IGraphMetrics _metrics;
|
||||||
private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
public InMemoryGraphExportService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
public InMemoryGraphExportService(IGraphRuntimeRepository repository, IGraphMetrics metrics)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
|
|
||||||
public sealed class InMemoryGraphLineageService : IGraphLineageService
|
public sealed class InMemoryGraphLineageService : IGraphLineageService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
|
|
||||||
public InMemoryGraphLineageService(InMemoryGraphRepository repository)
|
public InMemoryGraphLineageService(IGraphRuntimeRepository repository)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
|
|
||||||
public sealed class InMemoryGraphPathService : IGraphPathService
|
public sealed class InMemoryGraphPathService : IGraphPathService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
private readonly IOverlayService _overlayService;
|
private readonly IOverlayService _overlayService;
|
||||||
private readonly IGraphMetrics _metrics;
|
private readonly IGraphMetrics _metrics;
|
||||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||||
@@ -16,7 +16,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
|||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
|
public InMemoryGraphPathService(IGraphRuntimeRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_overlayService = overlayService;
|
_overlayService = overlayService;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
|
|
||||||
public sealed class InMemoryGraphQueryService : IGraphQueryService
|
public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private readonly IOverlayService _overlayService;
|
private readonly IOverlayService _overlayService;
|
||||||
private readonly IGraphMetrics _metrics;
|
private readonly IGraphMetrics _metrics;
|
||||||
@@ -19,7 +19,7 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
|||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
public InMemoryGraphQueryService(InMemoryGraphRepository repository, IMemoryCache cache, IOverlayService overlayService, IGraphMetrics metrics)
|
public InMemoryGraphQueryService(IGraphRuntimeRepository repository, IMemoryCache cache, IOverlayService overlayService, IGraphMetrics metrics)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using StellaOps.Graph.Api.Contracts;
|
|||||||
|
|
||||||
namespace StellaOps.Graph.Api.Services;
|
namespace StellaOps.Graph.Api.Services;
|
||||||
|
|
||||||
public sealed class InMemoryGraphRepository
|
public sealed class InMemoryGraphRepository : IGraphRuntimeRepository
|
||||||
{
|
{
|
||||||
private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"];
|
private static readonly string[] CompatibilityKinds = ["artifact", "component", "vuln"];
|
||||||
private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
|
||||||
@@ -310,7 +310,7 @@ public sealed class InMemoryGraphRepository
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DateTimeOffset GetSnapshotTimestamp()
|
public DateTimeOffset GetSnapshotTimestamp(string tenant)
|
||||||
=> FixedSnapshotAt;
|
=> FixedSnapshotAt;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ namespace StellaOps.Graph.Api.Services;
|
|||||||
|
|
||||||
public sealed class InMemoryGraphSearchService : IGraphSearchService
|
public sealed class InMemoryGraphSearchService : IGraphSearchService
|
||||||
{
|
{
|
||||||
private readonly InMemoryGraphRepository _repository;
|
private readonly IGraphRuntimeRepository _repository;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||||
{
|
{
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
|
|
||||||
public InMemoryGraphSearchService(InMemoryGraphRepository repository, IMemoryCache cache)
|
public InMemoryGraphSearchService(IGraphRuntimeRepository repository, IMemoryCache cache)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var sql = $"SELECT id, document_json FROM {_schemaName}.graph_nodes ORDER BY id";
|
var sql = $"SELECT id, document_json::text FROM {_schemaName}.graph_nodes ORDER BY id";
|
||||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var sql = $"SELECT id, source_id, target_id, document_json FROM {_schemaName}.graph_edges ORDER BY id";
|
var sql = $"SELECT id, source_id, target_id, document_json::text FROM {_schemaName}.graph_edges ORDER BY id";
|
||||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var sql = $"SELECT id, document_json FROM {_schemaName}.graph_nodes ORDER BY id";
|
var sql = $"SELECT id, document_json::text FROM {_schemaName}.graph_nodes ORDER BY id";
|
||||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var sql = $"SELECT id, source_id, target_id, document_json FROM {_schemaName}.graph_edges ORDER BY id";
|
var sql = $"SELECT id, source_id, target_id, document_json::text FROM {_schemaName}.graph_edges ORDER BY id";
|
||||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
|||||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NodeTile? ParseNodeTile(string json)
|
internal static NodeTile? ParseNodeTile(string json)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -261,7 +261,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EdgeTile? ParseEdgeTile(string json)
|
internal static EdgeTile? ParseEdgeTile(string json)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Npgsql;
|
||||||
|
using StellaOps.Graph.Api.Contracts;
|
||||||
|
using StellaOps.Infrastructure.Postgres.Options;
|
||||||
|
|
||||||
|
namespace StellaOps.Graph.Api.Services;
|
||||||
|
|
||||||
|
public sealed class PostgresGraphRuntimeRepository : IGraphRuntimeRepository, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly PostgresGraphRepository _graphRepository;
|
||||||
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
|
private readonly string _schemaName;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<PostgresGraphRuntimeRepository> _logger;
|
||||||
|
|
||||||
|
public PostgresGraphRuntimeRepository(
|
||||||
|
IOptions<PostgresOptions> options,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<PostgresGraphRuntimeRepository> logger,
|
||||||
|
ILogger<PostgresGraphRepository> graphRepositoryLogger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var connectionString = options.Value.ConnectionString
|
||||||
|
?? throw new InvalidOperationException("Graph Postgres connection string is required for PostgresGraphRuntimeRepository.");
|
||||||
|
|
||||||
|
_graphRepository = new PostgresGraphRepository(options, graphRepositoryLogger);
|
||||||
|
_dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
_schemaName = string.IsNullOrWhiteSpace(options.Value.SchemaName) ? "graph" : options.Value.SchemaName.Trim();
|
||||||
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request)
|
||||||
|
=> LoadTenantRepository(tenant).Query(tenant, request);
|
||||||
|
|
||||||
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request)
|
||||||
|
=> LoadTenantRepository(tenant).QueryGraph(tenant, request);
|
||||||
|
|
||||||
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) GetLineage(string tenant, GraphLineageRequest request)
|
||||||
|
=> LoadTenantRepository(tenant).GetLineage(tenant, request);
|
||||||
|
|
||||||
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
|
||||||
|
=> GetSnapshotAsync(tenant, snapshotId).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
public IReadOnlyList<NodeTile> GetCompatibilityNodes(string tenant)
|
||||||
|
=> LoadTenantRepository(tenant).GetCompatibilityNodes(tenant);
|
||||||
|
|
||||||
|
public IReadOnlyList<EdgeTile> GetCompatibilityEdges(string tenant)
|
||||||
|
=> LoadTenantRepository(tenant).GetCompatibilityEdges(tenant);
|
||||||
|
|
||||||
|
public bool GraphExists(string tenant, string graphId)
|
||||||
|
=> string.Equals(InMemoryGraphRepository.BuildGraphId(tenant), graphId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public NodeTile? GetNode(string tenant, string nodeId)
|
||||||
|
=> LoadTenantRepository(tenant).GetNode(tenant, nodeId);
|
||||||
|
|
||||||
|
public (IReadOnlyList<EdgeTile> Incoming, IReadOnlyList<EdgeTile> Outgoing) GetAdjacency(string tenant, string nodeId)
|
||||||
|
=> LoadTenantRepository(tenant).GetAdjacency(tenant, nodeId);
|
||||||
|
|
||||||
|
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth)
|
||||||
|
=> LoadTenantRepository(tenant).FindCompatibilityPath(tenant, sourceId, targetId, maxDepth);
|
||||||
|
|
||||||
|
public DateTimeOffset GetSnapshotTimestamp(string tenant)
|
||||||
|
=> GetSnapshotTimestampAsync(tenant).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InMemoryGraphRepository LoadTenantRepository(string tenant)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nodes = _graphRepository.GetNodesAsync(tenant).GetAwaiter().GetResult();
|
||||||
|
var edges = _graphRepository.GetEdgesAsync(tenant).GetAwaiter().GetResult();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Graph runtime loaded {NodeCount} nodes and {EdgeCount} edges for tenant {Tenant} from schema {Schema}",
|
||||||
|
nodes.Count,
|
||||||
|
edges.Count,
|
||||||
|
tenant,
|
||||||
|
_schemaName);
|
||||||
|
return new InMemoryGraphRepository(nodes, edges);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Graph runtime repository fell back to an empty graph for tenant {Tenant}", tenant);
|
||||||
|
return new InMemoryGraphRepository(Array.Empty<NodeTile>(), Array.Empty<EdgeTile>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)?> GetSnapshotAsync(
|
||||||
|
string tenant,
|
||||||
|
string snapshotId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = $"""
|
||||||
|
SELECT nodes_json, edges_json
|
||||||
|
FROM {_schemaName}.pending_snapshots
|
||||||
|
WHERE tenant = @tenant AND snapshot_id = @snapshotId
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("tenant", tenant);
|
||||||
|
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodesJson = reader.GetString(0);
|
||||||
|
var edgesJson = reader.GetString(1);
|
||||||
|
return (ParseNodeArray(nodesJson), ParseEdgeArray(edgesJson));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read graph snapshot {SnapshotId} for tenant {Tenant}", snapshotId, tenant);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DateTimeOffset> GetSnapshotTimestampAsync(string tenant, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = $"""
|
||||||
|
SELECT COALESCE(
|
||||||
|
(SELECT MAX(generated_at) FROM {_schemaName}.pending_snapshots WHERE tenant = @tenant),
|
||||||
|
(SELECT MAX(written_at) FROM {_schemaName}.graph_nodes WHERE document_json->>'tenant' = @tenant),
|
||||||
|
(SELECT MAX(written_at) FROM {_schemaName}.graph_edges WHERE document_json->>'tenant' = @tenant),
|
||||||
|
@fallback)
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("tenant", tenant);
|
||||||
|
command.Parameters.AddWithValue("fallback", _timeProvider.GetUtcNow());
|
||||||
|
|
||||||
|
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return value is DateTimeOffset timestamp ? timestamp : _timeProvider.GetUtcNow();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to resolve graph snapshot timestamp for tenant {Tenant}", tenant);
|
||||||
|
return _timeProvider.GetUtcNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<NodeTile> ParseNodeArray(string json)
|
||||||
|
{
|
||||||
|
var items = ParseSnapshotItems(json);
|
||||||
|
var nodes = new List<NodeTile>(items.Count);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var node = PostgresGraphRepository.ParseNodeTile(item);
|
||||||
|
if (node is not null)
|
||||||
|
{
|
||||||
|
nodes.Add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<EdgeTile> ParseEdgeArray(string json)
|
||||||
|
{
|
||||||
|
var items = ParseSnapshotItems(json);
|
||||||
|
var edges = new List<EdgeTile>(items.Count);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var edge = PostgresGraphRepository.ParseEdgeTile(item);
|
||||||
|
if (edge is not null)
|
||||||
|
{
|
||||||
|
edges.Add(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseSnapshotItems(string json)
|
||||||
|
{
|
||||||
|
var items = new List<string>();
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var element in document.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
switch (element.ValueKind)
|
||||||
|
{
|
||||||
|
case JsonValueKind.String:
|
||||||
|
items.Add(element.GetString() ?? string.Empty);
|
||||||
|
break;
|
||||||
|
case JsonValueKind.Object:
|
||||||
|
items.Add(element.GetRawText());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
|||||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||||
| SPRINT-20260222-058-GRAPH-TEN | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: migrated Graph endpoint tenant/scope checks to shared resolver + policy-driven authorization (tenant-aware limiter/audit included). |
|
| SPRINT-20260222-058-GRAPH-TEN | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: migrated Graph endpoint tenant/scope checks to shared resolver + policy-driven authorization (tenant-aware limiter/audit included). |
|
||||||
| SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Graph API and localized selected edge/export validation messages (`en-US`/`de-DE`). |
|
| SPRINT-20260224-002-LOC-101 | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: adopted StellaOps localization runtime bundle loading in Graph API and localized selected edge/export validation messages (`en-US`/`de-DE`). |
|
||||||
|
| SPRINT-20260410-001-GRAPH-RUNTIME | DONE | `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: Graph API runtime repository selection now resolves from final `Postgres:Graph` options, and the live query/diff/compatibility surfaces no longer fall back to demo-seeded graph data when Postgres is configured. |
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
namespace StellaOps.Graph.Api.Tests;
|
namespace StellaOps.Graph.Api.Tests;
|
||||||
|
|
||||||
public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<GraphApiPostgresFixture>
|
public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<GraphApiPostgresFixture>
|
||||||
{
|
{
|
||||||
|
private const string RuntimeSchema = "graph";
|
||||||
private readonly GraphApiPostgresFixture _fixture;
|
private readonly GraphApiPostgresFixture _fixture;
|
||||||
|
|
||||||
public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture)
|
public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture)
|
||||||
@@ -25,6 +27,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
|||||||
await _fixture.TruncateAllTablesAsync();
|
await _fixture.TruncateAllTablesAsync();
|
||||||
using var factory = CreateFactory();
|
using var factory = CreateFactory();
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
await ClearRuntimeTablesAsync();
|
||||||
|
await SeedBasicGraphAsync();
|
||||||
using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read");
|
using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read");
|
||||||
|
|
||||||
var response = await client.SendAsync(request);
|
var response = await client.SendAsync(request);
|
||||||
@@ -36,6 +40,7 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
|||||||
var items = document.RootElement.GetProperty("items");
|
var items = document.RootElement.GetProperty("items");
|
||||||
Assert.True(items.GetArrayLength() > 0);
|
Assert.True(items.GetArrayLength() > 0);
|
||||||
Assert.Equal("ready", items[0].GetProperty("status").GetString());
|
Assert.Equal("ready", items[0].GetProperty("status").GetString());
|
||||||
|
Assert.Equal(3, items[0].GetProperty("nodeCount").GetInt32());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -46,6 +51,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
|||||||
await _fixture.TruncateAllTablesAsync();
|
await _fixture.TruncateAllTablesAsync();
|
||||||
using var factory = CreateFactory();
|
using var factory = CreateFactory();
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
await ClearRuntimeTablesAsync();
|
||||||
|
await SeedBasicGraphAsync();
|
||||||
var graphId = await GetGraphIdAsync(client);
|
var graphId = await GetGraphIdAsync(client);
|
||||||
using var request = CreateRequest(
|
using var request = CreateRequest(
|
||||||
HttpMethod.Get,
|
HttpMethod.Get,
|
||||||
@@ -75,6 +82,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
|||||||
await _fixture.TruncateAllTablesAsync();
|
await _fixture.TruncateAllTablesAsync();
|
||||||
using var factory = CreateFactory();
|
using var factory = CreateFactory();
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
await ClearRuntimeTablesAsync();
|
||||||
|
await SeedBasicGraphAsync();
|
||||||
var graphId = await GetGraphIdAsync(client);
|
var graphId = await GetGraphIdAsync(client);
|
||||||
var viewName = $"Priority view {Guid.NewGuid():N}";
|
var viewName = $"Priority view {Guid.NewGuid():N}";
|
||||||
|
|
||||||
@@ -136,7 +145,7 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
|||||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString,
|
["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString,
|
||||||
["Postgres:Graph:SchemaName"] = _fixture.SchemaName,
|
["Postgres:Graph:SchemaName"] = RuntimeSchema,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -161,4 +170,150 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
|||||||
return document.RootElement.GetProperty("items")[0].GetProperty("graphId").GetString()
|
return document.RootElement.GetProperty("items")[0].GetProperty("graphId").GetString()
|
||||||
?? throw new InvalidOperationException("Compatibility graph id was not returned.");
|
?? throw new InvalidOperationException("Compatibility graph id was not returned.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SeedBasicGraphAsync()
|
||||||
|
{
|
||||||
|
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var writtenAt = DateTimeOffset.Parse("2026-04-14T09:30:00Z");
|
||||||
|
|
||||||
|
await InsertNodeAsync(
|
||||||
|
connection,
|
||||||
|
"gn:acme:artifact:sha256:abc",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "gn:acme:artifact:sha256:abc",
|
||||||
|
"kind": "artifact",
|
||||||
|
"tenant": "acme",
|
||||||
|
"attributes": {
|
||||||
|
"displayName": "auth-service",
|
||||||
|
"artifact_digest": "sha256:abc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
writtenAt);
|
||||||
|
|
||||||
|
await InsertNodeAsync(
|
||||||
|
connection,
|
||||||
|
"gn:acme:component:widget",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "gn:acme:component:widget",
|
||||||
|
"kind": "component",
|
||||||
|
"tenant": "acme",
|
||||||
|
"attributes": {
|
||||||
|
"displayName": "widget",
|
||||||
|
"purl": "pkg:npm/widget@2.0.0",
|
||||||
|
"version": "2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
writtenAt);
|
||||||
|
|
||||||
|
await InsertNodeAsync(
|
||||||
|
connection,
|
||||||
|
"gn:acme:vuln:CVE-2026-1234",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "gn:acme:vuln:CVE-2026-1234",
|
||||||
|
"kind": "vuln",
|
||||||
|
"tenant": "acme",
|
||||||
|
"attributes": {
|
||||||
|
"displayName": "CVE-2026-1234",
|
||||||
|
"severity": "critical"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
writtenAt);
|
||||||
|
|
||||||
|
await InsertEdgeAsync(
|
||||||
|
connection,
|
||||||
|
"ge:acme:artifact->component",
|
||||||
|
"gn:acme:artifact:sha256:abc",
|
||||||
|
"gn:acme:component:widget",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "ge:acme:artifact->component",
|
||||||
|
"type": "builds",
|
||||||
|
"tenant": "acme",
|
||||||
|
"source": "gn:acme:artifact:sha256:abc",
|
||||||
|
"target": "gn:acme:component:widget",
|
||||||
|
"attributes": {
|
||||||
|
"sbom_digest": "sha256:sbom-a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
writtenAt);
|
||||||
|
|
||||||
|
await InsertEdgeAsync(
|
||||||
|
connection,
|
||||||
|
"ge:acme:component->vuln",
|
||||||
|
"gn:acme:component:widget",
|
||||||
|
"gn:acme:vuln:CVE-2026-1234",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "ge:acme:component->vuln",
|
||||||
|
"type": "affects",
|
||||||
|
"tenant": "acme",
|
||||||
|
"source": "gn:acme:component:widget",
|
||||||
|
"target": "gn:acme:vuln:CVE-2026-1234",
|
||||||
|
"attributes": {
|
||||||
|
"advisory_id": "CVE-2026-1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
writtenAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InsertNodeAsync(NpgsqlConnection connection, string id, string documentJson, DateTimeOffset writtenAt)
|
||||||
|
{
|
||||||
|
var sql = $"""
|
||||||
|
INSERT INTO {RuntimeSchema}.graph_nodes (id, tenant_id, node_type, data, created_at, batch_id, document_json, written_at)
|
||||||
|
VALUES (@id, @tenantId, @nodeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("id", id);
|
||||||
|
command.Parameters.AddWithValue("tenantId", "acme");
|
||||||
|
command.Parameters.AddWithValue("nodeType", "runtime");
|
||||||
|
command.Parameters.AddWithValue("batchId", $"seed:{id}");
|
||||||
|
command.Parameters.AddWithValue("documentJson", documentJson);
|
||||||
|
command.Parameters.AddWithValue("writtenAt", writtenAt);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InsertEdgeAsync(NpgsqlConnection connection, string id, string sourceId, string targetId, string documentJson, DateTimeOffset writtenAt)
|
||||||
|
{
|
||||||
|
var sql = $"""
|
||||||
|
INSERT INTO {RuntimeSchema}.graph_edges (id, tenant_id, source_id, target_id, edge_type, data, created_at, batch_id, document_json, written_at)
|
||||||
|
VALUES (@id, @tenantId, @sourceId, @targetId, @edgeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("id", id);
|
||||||
|
command.Parameters.AddWithValue("tenantId", "acme");
|
||||||
|
command.Parameters.AddWithValue("batchId", $"seed:{id}");
|
||||||
|
command.Parameters.AddWithValue("sourceId", sourceId);
|
||||||
|
command.Parameters.AddWithValue("targetId", targetId);
|
||||||
|
command.Parameters.AddWithValue("edgeType", "runtime");
|
||||||
|
command.Parameters.AddWithValue("documentJson", documentJson);
|
||||||
|
command.Parameters.AddWithValue("writtenAt", writtenAt);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ClearRuntimeTablesAsync()
|
||||||
|
{
|
||||||
|
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var sql = """
|
||||||
|
TRUNCATE TABLE
|
||||||
|
graph.saved_views,
|
||||||
|
graph.pending_snapshots,
|
||||||
|
graph.graph_edges,
|
||||||
|
graph.graph_nodes
|
||||||
|
RESTART IDENTITY
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace StellaOps.Graph.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class GraphPostgresRuntimeIntegrationTests : IClassFixture<GraphApiPostgresFixture>
|
||||||
|
{
|
||||||
|
private const string RuntimeSchema = "graph";
|
||||||
|
private readonly GraphApiPostgresFixture _fixture;
|
||||||
|
|
||||||
|
public GraphPostgresRuntimeIntegrationTests(GraphApiPostgresFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Intent", "Runtime")]
|
||||||
|
public async Task QueryEndpoint_ReadsPersistedGraphRows()
|
||||||
|
{
|
||||||
|
await _fixture.TruncateAllTablesAsync();
|
||||||
|
|
||||||
|
using var factory = CreateFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await ClearRuntimeTablesAsync();
|
||||||
|
await SeedGraphAsync();
|
||||||
|
using var request = CreateRequest(HttpMethod.Post, "/graph/query", "graph:query");
|
||||||
|
request.Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
kinds = new[] { "artifact", "component" },
|
||||||
|
query = "gn:acme",
|
||||||
|
includeEdges = true,
|
||||||
|
includeStats = true,
|
||||||
|
limit = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Contains("gn:acme:artifact:sha256:abc", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("gn:acme:component:widget", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"type\":\"edge\"", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"kind\":\"builds\"", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"source\":\"gn:acme:artifact:sha256:abc\"", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"target\":\"gn:acme:component:widget\"", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Intent", "Runtime")]
|
||||||
|
public async Task DiffEndpoint_ReadsPersistedPendingSnapshots()
|
||||||
|
{
|
||||||
|
await _fixture.TruncateAllTablesAsync();
|
||||||
|
|
||||||
|
using var factory = CreateFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await ClearRuntimeTablesAsync();
|
||||||
|
await SeedSnapshotsAsync();
|
||||||
|
using var request = CreateRequest(HttpMethod.Post, "/graph/diff", "graph:query");
|
||||||
|
request.Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
snapshotA = "snapA",
|
||||||
|
snapshotB = "snapB",
|
||||||
|
includeEdges = true,
|
||||||
|
includeStats = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Contains("\"type\":\"node_added\"", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("gn:acme:component:newlib", body, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"type\":\"edge_added\"", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebApplicationFactory<Program> CreateFactory()
|
||||||
|
{
|
||||||
|
return new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Development");
|
||||||
|
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||||
|
{
|
||||||
|
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Postgres:Graph:ConnectionString"] = _fixture.ConnectionString,
|
||||||
|
["Postgres:Graph:SchemaName"] = RuntimeSchema,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage CreateRequest(HttpMethod method, string url, string scopes)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(method, url);
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", "Bearer qa-token");
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Stella-Tenant", "acme");
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Stella-Scopes", scopes);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedGraphAsync()
|
||||||
|
{
|
||||||
|
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var writtenAt = DateTimeOffset.Parse("2026-04-14T10:00:00Z");
|
||||||
|
|
||||||
|
await InsertNodeAsync(connection, "gn:acme:artifact:sha256:abc", """
|
||||||
|
{"id":"gn:acme:artifact:sha256:abc","kind":"artifact","tenant":"acme","attributes":{"displayName":"auth-service","artifact_digest":"sha256:abc"}}
|
||||||
|
""", writtenAt);
|
||||||
|
await InsertNodeAsync(connection, "gn:acme:component:widget", """
|
||||||
|
{"id":"gn:acme:component:widget","kind":"component","tenant":"acme","attributes":{"displayName":"widget","purl":"pkg:npm/widget@2.0.0","version":"2.0.0"}}
|
||||||
|
""", writtenAt);
|
||||||
|
|
||||||
|
await InsertEdgeAsync(connection, "ge:acme:artifact->component", "gn:acme:artifact:sha256:abc", "gn:acme:component:widget", """
|
||||||
|
{"id":"ge:acme:artifact->component","type":"builds","tenant":"acme","source":"gn:acme:artifact:sha256:abc","target":"gn:acme:component:widget","attributes":{"sbom_digest":"sha256:sbom-a"}}
|
||||||
|
""", writtenAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedSnapshotsAsync()
|
||||||
|
{
|
||||||
|
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var snapshotA = JsonSerializer.Serialize(new[]
|
||||||
|
{
|
||||||
|
"""
|
||||||
|
{"id":"gn:acme:component:widget","kind":"component","tenant":"acme","attributes":{"displayName":"widget","version":"2.0.0"}}
|
||||||
|
"""
|
||||||
|
});
|
||||||
|
var edgesA = JsonSerializer.Serialize(Array.Empty<string>());
|
||||||
|
var snapshotB = JsonSerializer.Serialize(new[]
|
||||||
|
{
|
||||||
|
"""
|
||||||
|
{"id":"gn:acme:component:widget","kind":"component","tenant":"acme","attributes":{"displayName":"widget","version":"2.1.0"}}
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
{"id":"gn:acme:component:newlib","kind":"component","tenant":"acme","attributes":{"displayName":"newlib","version":"1.0.0"}}
|
||||||
|
"""
|
||||||
|
});
|
||||||
|
var edgesB = JsonSerializer.Serialize(new[]
|
||||||
|
{
|
||||||
|
"""
|
||||||
|
{"id":"ge:acme:component->component:new","type":"depends_on","tenant":"acme","source":"gn:acme:component:widget","target":"gn:acme:component:newlib","attributes":{"scope":"runtime"}}
|
||||||
|
"""
|
||||||
|
});
|
||||||
|
|
||||||
|
await InsertSnapshotAsync(connection, "snapA", snapshotA, edgesA, DateTimeOffset.Parse("2026-04-14T09:55:00Z"));
|
||||||
|
await InsertSnapshotAsync(connection, "snapB", snapshotB, edgesB, DateTimeOffset.Parse("2026-04-14T10:05:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InsertNodeAsync(NpgsqlConnection connection, string id, string documentJson, DateTimeOffset writtenAt)
|
||||||
|
{
|
||||||
|
var sql = $"""
|
||||||
|
INSERT INTO {RuntimeSchema}.graph_nodes (id, tenant_id, node_type, data, created_at, batch_id, document_json, written_at)
|
||||||
|
VALUES (@id, @tenantId, @nodeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("id", id);
|
||||||
|
command.Parameters.AddWithValue("tenantId", "acme");
|
||||||
|
command.Parameters.AddWithValue("nodeType", "runtime");
|
||||||
|
command.Parameters.AddWithValue("batchId", $"seed:{id}");
|
||||||
|
command.Parameters.AddWithValue("documentJson", documentJson);
|
||||||
|
command.Parameters.AddWithValue("writtenAt", writtenAt);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InsertEdgeAsync(NpgsqlConnection connection, string id, string sourceId, string targetId, string documentJson, DateTimeOffset writtenAt)
|
||||||
|
{
|
||||||
|
var sql = $"""
|
||||||
|
INSERT INTO {RuntimeSchema}.graph_edges (id, tenant_id, source_id, target_id, edge_type, data, created_at, batch_id, document_json, written_at)
|
||||||
|
VALUES (@id, @tenantId, @sourceId, @targetId, @edgeType, @documentJson::jsonb, @writtenAt, @batchId, @documentJson::jsonb, @writtenAt)
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("id", id);
|
||||||
|
command.Parameters.AddWithValue("tenantId", "acme");
|
||||||
|
command.Parameters.AddWithValue("batchId", $"seed:{id}");
|
||||||
|
command.Parameters.AddWithValue("sourceId", sourceId);
|
||||||
|
command.Parameters.AddWithValue("targetId", targetId);
|
||||||
|
command.Parameters.AddWithValue("edgeType", "runtime");
|
||||||
|
command.Parameters.AddWithValue("documentJson", documentJson);
|
||||||
|
command.Parameters.AddWithValue("writtenAt", writtenAt);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InsertSnapshotAsync(NpgsqlConnection connection, string snapshotId, string nodesJson, string edgesJson, DateTimeOffset generatedAt)
|
||||||
|
{
|
||||||
|
var sql = $"""
|
||||||
|
INSERT INTO {RuntimeSchema}.pending_snapshots (tenant, snapshot_id, generated_at, nodes_json, edges_json, queued_at)
|
||||||
|
VALUES ('acme', @snapshotId, @generatedAt, @nodesJson::jsonb, @edgesJson::jsonb, @generatedAt)
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("snapshotId", snapshotId);
|
||||||
|
command.Parameters.AddWithValue("generatedAt", generatedAt);
|
||||||
|
command.Parameters.AddWithValue("nodesJson", nodesJson);
|
||||||
|
command.Parameters.AddWithValue("edgesJson", edgesJson);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ClearRuntimeTablesAsync()
|
||||||
|
{
|
||||||
|
await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var sql = """
|
||||||
|
TRUNCATE TABLE
|
||||||
|
graph.saved_views,
|
||||||
|
graph.pending_snapshots,
|
||||||
|
graph.graph_edges,
|
||||||
|
graph.graph_nodes
|
||||||
|
RESTART IDENTITY
|
||||||
|
""";
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StellaOps.Graph.Api.Services;
|
||||||
|
|
||||||
|
namespace StellaOps.Graph.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class GraphRuntimeRepositoryRegistrationTests : IClassFixture<GraphApiPostgresFixture>
|
||||||
|
{
|
||||||
|
private readonly GraphApiPostgresFixture _fixture;
|
||||||
|
|
||||||
|
public GraphRuntimeRepositoryRegistrationTests(GraphApiPostgresFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Intent", "Registration")]
|
||||||
|
public void Resolves_PostgresRuntimeRepository_WhenConnectionStringIsConfigured()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory(_fixture.ConnectionString);
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<IGraphRuntimeRepository>();
|
||||||
|
|
||||||
|
Assert.IsType<PostgresGraphRuntimeRepository>(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Intent", "Registration")]
|
||||||
|
public void Resolves_EmptyInMemoryRuntimeRepository_WhenConnectionStringIsMissing()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory(string.Empty);
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<IGraphRuntimeRepository>();
|
||||||
|
var inMemoryRepository = Assert.IsType<InMemoryGraphRepository>(repository);
|
||||||
|
|
||||||
|
Assert.Empty(inMemoryRepository.GetCompatibilityNodes("acme"));
|
||||||
|
Assert.Empty(inMemoryRepository.GetCompatibilityEdges("acme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WebApplicationFactory<Program> CreateFactory(string? connectionString)
|
||||||
|
{
|
||||||
|
return new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Development");
|
||||||
|
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||||
|
{
|
||||||
|
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Postgres:Graph:ConnectionString"] = connectionString,
|
||||||
|
["Postgres:Graph:SchemaName"] = "graph",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,3 +15,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
|||||||
| QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. |
|
| QA-GRAPH-RECHECK-006 | DONE | SPRINT_20260210_005: known-edge metadata positive-path integration test added to catch empty-runtime-data regressions. |
|
||||||
| SPRINT-20260222-058-GRAPH-TEN-05 | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: added focused Graph tenant/auth alignment tests and executed Graph API test project evidence run (`73 passed`). |
|
| SPRINT-20260222-058-GRAPH-TEN-05 | DONE | `docs/implplan/SPRINT_20260222_058_Graph_tenant_resolution_and_auth_alignment.md`: added focused Graph tenant/auth alignment tests and executed Graph API test project evidence run (`73 passed`). |
|
||||||
| SPRINT-20260224-002-LOC-101-T | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: added focused Graph API locale-aware unknown-edge test and validated German localized error text. |
|
| SPRINT-20260224-002-LOC-101-T | DONE | `SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: added focused Graph API locale-aware unknown-edge test and validated German localized error text. |
|
||||||
|
| SPRINT-20260410-001-GRAPH-RUNTIME-T | DONE | `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: added focused Graph runtime registration, compatibility, and Postgres integration tests proving the live host resolves persisted graph state instead of the demo/in-memory path when `Postgres:Graph` is configured. |
|
||||||
|
|||||||
Reference in New Issue
Block a user