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/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.
|
||||
- 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:
|
||||
- `GET /graphs`, `GET /graphs/{graphId}`, `GET /graphs/{graphId}/tiles`
|
||||
- `GET /search`, `GET /paths`
|
||||
|
||||
@@ -13,7 +13,7 @@ public static class CompatibilityEndpoints
|
||||
{
|
||||
app.MapGet("/graphs", async Task<IResult> (
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
@@ -26,7 +26,7 @@ public static class CompatibilityEndpoints
|
||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||
var graphId = InMemoryGraphRepository.BuildGraphId(tenantId);
|
||||
var snapshotAt = repository.GetSnapshotTimestamp();
|
||||
var snapshotAt = repository.GetSnapshotTimestamp(tenantId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
@@ -55,7 +55,7 @@ public static class CompatibilityEndpoints
|
||||
app.MapGet("/graphs/{graphId}", async Task<IResult> (
|
||||
string graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
@@ -72,7 +72,7 @@ public static class CompatibilityEndpoints
|
||||
|
||||
var nodes = repository.GetCompatibilityNodes(tenantId);
|
||||
var edges = repository.GetCompatibilityEdges(tenantId);
|
||||
var snapshotAt = repository.GetSnapshotTimestamp();
|
||||
var snapshotAt = repository.GetSnapshotTimestamp(tenantId);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
@@ -95,7 +95,7 @@ public static class CompatibilityEndpoints
|
||||
string graphId,
|
||||
bool? includeOverlays,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
IOverlayService overlayService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
@@ -152,7 +152,7 @@ public static class CompatibilityEndpoints
|
||||
string? graphId,
|
||||
int? pageSize,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
@@ -211,7 +211,7 @@ public static class CompatibilityEndpoints
|
||||
int? maxDepth,
|
||||
string? graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.Query, ct);
|
||||
@@ -257,7 +257,7 @@ public static class CompatibilityEndpoints
|
||||
string graphId,
|
||||
string? format,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
IGraphExportService exportService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
@@ -295,7 +295,7 @@ public static class CompatibilityEndpoints
|
||||
app.MapGet("/assets/{assetId}/snapshot", async Task<IResult> (
|
||||
string assetId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var auth = await AuthorizeAsync(context, GraphPolicies.ReadOrQuery, ct);
|
||||
@@ -331,7 +331,7 @@ public static class CompatibilityEndpoints
|
||||
kind = NormalizeNodeKind(asset.Kind),
|
||||
components = componentIds,
|
||||
vulnerabilities,
|
||||
snapshotAt = repository.GetSnapshotTimestamp()
|
||||
snapshotAt = repository.GetSnapshotTimestamp(tenantId)
|
||||
});
|
||||
})
|
||||
.RequireTenant();
|
||||
@@ -340,7 +340,7 @@ public static class CompatibilityEndpoints
|
||||
string nodeId,
|
||||
string? graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
CancellationToken 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> (
|
||||
string graphId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
IGraphSavedViewStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
@@ -405,7 +405,7 @@ public static class CompatibilityEndpoints
|
||||
string graphId,
|
||||
CreateGraphSavedViewRequest request,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
IGraphSavedViewStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
@@ -439,7 +439,7 @@ public static class CompatibilityEndpoints
|
||||
string graphId,
|
||||
string viewId,
|
||||
HttpContext context,
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
IGraphSavedViewStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -22,12 +22,16 @@ using static StellaOps.Localization.T;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
// When Postgres is configured, start with an empty repository (data loaded from DB by hosted service).
|
||||
// When no Postgres, fall back to hardcoded seed data for demo/development.
|
||||
var hasPostgres = !string.IsNullOrWhiteSpace(ResolveGraphConnectionString(builder.Configuration));
|
||||
builder.Services.AddSingleton(_ => hasPostgres
|
||||
? new InMemoryGraphRepository(seed: Array.Empty<NodeTile>(), edges: Array.Empty<EdgeTile>())
|
||||
: new InMemoryGraphRepository());
|
||||
builder.Services.AddSingleton<IGraphRuntimeRepository>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.ConnectionString))
|
||||
{
|
||||
return new InMemoryGraphRepository(seed: Array.Empty<NodeTile>(), edges: Array.Empty<EdgeTile>());
|
||||
}
|
||||
|
||||
return ActivatorUtilities.CreateInstance<PostgresGraphRuntimeRepository>(sp);
|
||||
});
|
||||
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
||||
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
||||
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
||||
@@ -84,12 +88,6 @@ builder.Services.AddSingleton<PostgresGraphRepository>(sp =>
|
||||
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
|
||||
.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>
|
||||
public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
private readonly ILogger<InMemoryEdgeMetadataService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
private readonly Dictionary<string, EdgeExplanationPayload> _explanations;
|
||||
|
||||
public InMemoryEdgeMetadataService(
|
||||
InMemoryGraphRepository repository,
|
||||
IGraphRuntimeRepository repository,
|
||||
ILogger<InMemoryEdgeMetadataService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
@@ -31,8 +31,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
|
||||
// Seed with default explanations for demo/test data
|
||||
_explanations = SeedDefaultExplanations();
|
||||
_explanations = new Dictionary<string, EdgeExplanationPayload>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public Task<EdgeMetadataResponse> GetEdgeMetadataAsync(
|
||||
@@ -121,11 +120,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
|
||||
foreach (var edge in allEdges)
|
||||
{
|
||||
if (!_explanations.TryGetValue(edge.Id, out var explanation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
|
||||
if (explanation.Reason == reason)
|
||||
{
|
||||
matchingEdges.Add(ToEdgeTileWithMetadata(edge, explanation));
|
||||
@@ -159,11 +154,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
|
||||
foreach (var edge in allEdges)
|
||||
{
|
||||
if (!_explanations.TryGetValue(edge.Id, out var explanation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var explanation = GetOrCreateExplanation(edge, includeProvenance: true, includeEvidence: true);
|
||||
if (explanation.Evidence is not null &&
|
||||
explanation.Evidence.TryGetValue(evidenceType, out var refValue) &&
|
||||
string.Equals(refValue, evidenceRef, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -239,6 +230,13 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
ConfidenceBps = 10000
|
||||
},
|
||||
Summary = summary,
|
||||
Provenance = new EdgeProvenanceRef
|
||||
{
|
||||
Source = "graph-indexer",
|
||||
CollectedAt = now,
|
||||
SbomDigest = TryResolveEvidence(edge, "sbom", "sbom_digest")
|
||||
},
|
||||
Evidence = BuildEvidence(edge),
|
||||
Tags = [edge.Kind]
|
||||
};
|
||||
}
|
||||
@@ -335,64 +333,35 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService
|
||||
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(
|
||||
"sha256:sbom-a",
|
||||
"sbom-parser",
|
||||
now.AddHours(-1),
|
||||
"Build artifact produces component"),
|
||||
evidence[evidenceType] = value;
|
||||
}
|
||||
}
|
||||
|
||||
["ge:acme:component->component"] = new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.SbomDependency,
|
||||
Via = new EdgeVia
|
||||
{
|
||||
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"]
|
||||
},
|
||||
private static string? TryResolveEvidence(EdgeTile edge, string evidenceType, string attributeName)
|
||||
{
|
||||
if (edge.Attributes.TryGetValue(attributeName, out var attributeValue) && attributeValue is not null)
|
||||
{
|
||||
return attributeValue.ToString();
|
||||
}
|
||||
|
||||
["ge:acme:sbom->artifact"] = new EdgeExplanationPayload
|
||||
{
|
||||
Reason = EdgeReason.SbomDependency,
|
||||
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"]
|
||||
}
|
||||
};
|
||||
return edge.Attributes.TryGetValue(evidenceType, out var directValue) && directValue is not null
|
||||
? directValue.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +373,7 @@ public static class InMemoryGraphRepositoryExtensions
|
||||
/// <summary>
|
||||
/// Gets a single edge by ID.
|
||||
/// </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
|
||||
{
|
||||
@@ -419,7 +388,7 @@ public static class InMemoryGraphRepositoryExtensions
|
||||
/// <summary>
|
||||
/// Gets all edges for a tenant.
|
||||
/// </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
|
||||
{
|
||||
|
||||
@@ -8,14 +8,14 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphDiffService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
||||
public InMemoryGraphDiffService(IGraphRuntimeRepository repository, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_metrics = metrics;
|
||||
|
||||
@@ -8,11 +8,11 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphExportService : IGraphExportService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public InMemoryGraphExportService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
||||
public InMemoryGraphExportService(IGraphRuntimeRepository repository, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_metrics = metrics;
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphLineageService : IGraphLineageService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
|
||||
public InMemoryGraphLineageService(InMemoryGraphRepository repository)
|
||||
public InMemoryGraphLineageService(IGraphRuntimeRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
private readonly IOverlayService _overlayService;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
@@ -16,7 +16,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
|
||||
public InMemoryGraphPathService(IGraphRuntimeRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_overlayService = overlayService;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IOverlayService _overlayService;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
@@ -19,7 +19,7 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
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;
|
||||
_cache = cache;
|
||||
|
||||
@@ -2,7 +2,7 @@ using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
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 DateTimeOffset FixedSnapshotAt = new(2026, 4, 4, 0, 0, 0, TimeSpan.Zero);
|
||||
@@ -310,7 +310,7 @@ public sealed class InMemoryGraphRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
public DateTimeOffset GetSnapshotTimestamp()
|
||||
public DateTimeOffset GetSnapshotTimestamp(string tenant)
|
||||
=> FixedSnapshotAt;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,14 +9,14 @@ namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphSearchService : IGraphSearchService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphRuntimeRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphSearchService(InMemoryGraphRepository repository, IMemoryCache cache)
|
||||
public InMemoryGraphSearchService(IGraphRuntimeRepository repository, IMemoryCache cache)
|
||||
{
|
||||
_repository = repository;
|
||||
_cache = cache;
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
||||
{
|
||||
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 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);
|
||||
|
||||
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 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);
|
||||
|
||||
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 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);
|
||||
|
||||
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 reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
@@ -219,7 +219,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static NodeTile? ParseNodeTile(string json)
|
||||
internal static NodeTile? ParseNodeTile(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -261,7 +261,7 @@ public sealed class PostgresGraphRepository : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static EdgeTile? ParseEdgeTile(string json)
|
||||
internal static EdgeTile? ParseEdgeTile(string json)
|
||||
{
|
||||
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. |
|
||||
| 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-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.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<GraphApiPostgresFixture>
|
||||
{
|
||||
private const string RuntimeSchema = "graph";
|
||||
private readonly GraphApiPostgresFixture _fixture;
|
||||
|
||||
public GraphCompatibilityEndpointsIntegrationTests(GraphApiPostgresFixture fixture)
|
||||
@@ -25,6 +27,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
await ClearRuntimeTablesAsync();
|
||||
await SeedBasicGraphAsync();
|
||||
using var request = CreateRequest(HttpMethod.Get, "/graphs", "graph:read");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
@@ -36,6 +40,7 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
||||
var items = document.RootElement.GetProperty("items");
|
||||
Assert.True(items.GetArrayLength() > 0);
|
||||
Assert.Equal("ready", items[0].GetProperty("status").GetString());
|
||||
Assert.Equal(3, items[0].GetProperty("nodeCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -46,6 +51,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
await ClearRuntimeTablesAsync();
|
||||
await SeedBasicGraphAsync();
|
||||
var graphId = await GetGraphIdAsync(client);
|
||||
using var request = CreateRequest(
|
||||
HttpMethod.Get,
|
||||
@@ -75,6 +82,8 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
await ClearRuntimeTablesAsync();
|
||||
await SeedBasicGraphAsync();
|
||||
var graphId = await GetGraphIdAsync(client);
|
||||
var viewName = $"Priority view {Guid.NewGuid():N}";
|
||||
|
||||
@@ -136,7 +145,7 @@ public sealed class GraphCompatibilityEndpointsIntegrationTests : IClassFixture<
|
||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["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()
|
||||
?? 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. |
|
||||
| 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-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