diff --git a/docs/modules/graph/architecture.md b/docs/modules/graph/architecture.md index 28ed5bde6..e08fe1ce9 100644 --- a/docs/modules/graph/architecture.md +++ b/docs/modules/graph/architecture.md @@ -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` diff --git a/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs b/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs index 34882c6bb..f094b093a 100644 --- a/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs +++ b/src/Graph/StellaOps.Graph.Api/Endpoints/CompatibilityEndpoints.cs @@ -13,7 +13,7 @@ public static class CompatibilityEndpoints { app.MapGet("/graphs", async Task ( 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 ( 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 ( 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 ( 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) => { diff --git a/src/Graph/StellaOps.Graph.Api/Program.cs b/src/Graph/StellaOps.Graph.Api/Program.cs index 90b93547f..0a798b3e2 100644 --- a/src/Graph/StellaOps.Graph.Api/Program.cs +++ b/src/Graph/StellaOps.Graph.Api/Program.cs @@ -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(), edges: Array.Empty()) - : new InMemoryGraphRepository()); +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + if (string.IsNullOrWhiteSpace(options.ConnectionString)) + { + return new InMemoryGraphRepository(seed: Array.Empty(), edges: Array.Empty()); + } + + return ActivatorUtilities.CreateInstance(sp); +}); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -84,12 +88,6 @@ builder.Services.AddSingleton(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(); -} builder.Services .AddAuthentication(options => { diff --git a/src/Graph/StellaOps.Graph.Api/Services/IGraphRuntimeRepository.cs b/src/Graph/StellaOps.Graph.Api/Services/IGraphRuntimeRepository.cs new file mode 100644 index 000000000..ff49e0031 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Services/IGraphRuntimeRepository.cs @@ -0,0 +1,18 @@ +using StellaOps.Graph.Api.Contracts; + +namespace StellaOps.Graph.Api.Services; + +public interface IGraphRuntimeRepository +{ + IEnumerable Query(string tenant, GraphSearchRequest request); + (IReadOnlyList Nodes, IReadOnlyList Edges) QueryGraph(string tenant, GraphQueryRequest request); + (IReadOnlyList Nodes, IReadOnlyList Edges) GetLineage(string tenant, GraphLineageRequest request); + (IReadOnlyList Nodes, IReadOnlyList Edges)? GetSnapshot(string tenant, string snapshotId); + IReadOnlyList GetCompatibilityNodes(string tenant); + IReadOnlyList GetCompatibilityEdges(string tenant); + bool GraphExists(string tenant, string graphId); + NodeTile? GetNode(string tenant, string nodeId); + (IReadOnlyList Incoming, IReadOnlyList Outgoing) GetAdjacency(string tenant, string nodeId); + (IReadOnlyList Nodes, IReadOnlyList Edges)? FindCompatibilityPath(string tenant, string sourceId, string targetId, int maxDepth); + DateTimeOffset GetSnapshotTimestamp(string tenant); +} diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryEdgeMetadataService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryEdgeMetadataService.cs index c3117aaeb..ee8836bc6 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryEdgeMetadataService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryEdgeMetadataService.cs @@ -15,7 +15,7 @@ namespace StellaOps.Graph.Api.Services; /// public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService { - private readonly InMemoryGraphRepository _repository; + private readonly IGraphRuntimeRepository _repository; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; @@ -23,7 +23,7 @@ public sealed class InMemoryEdgeMetadataService : IEdgeMetadataService private readonly Dictionary _explanations; public InMemoryEdgeMetadataService( - InMemoryGraphRepository repository, + IGraphRuntimeRepository repository, ILogger 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(StringComparer.Ordinal); } public Task 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(); } - private Dictionary SeedDefaultExplanations() + private static Dictionary? BuildEvidence(EdgeTile edge) { - var now = _timeProvider.GetUtcNow(); + var evidence = new Dictionary(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(StringComparer.Ordinal) + private static void AddEvidence(Dictionary 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 { ["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 /// /// Gets a single edge by ID. /// - 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 /// /// Gets all edges for a tenant. /// - public static IReadOnlyList GetAllEdges(this InMemoryGraphRepository repository, string tenant) + public static IReadOnlyList GetAllEdges(this IGraphRuntimeRepository repository, string tenant) { var (_, edges) = repository.QueryGraph(tenant, new GraphQueryRequest { diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphDiffService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphDiffService.cs index 502dca2bb..58a903bc2 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphDiffService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphDiffService.cs @@ -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; diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphExportService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphExportService.cs index 6aec64ff0..77d07ae6b 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphExportService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphExportService.cs @@ -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 _jobs = new(StringComparer.Ordinal); - public InMemoryGraphExportService(InMemoryGraphRepository repository, IGraphMetrics metrics) + public InMemoryGraphExportService(IGraphRuntimeRepository repository, IGraphMetrics metrics) { _repository = repository; _metrics = metrics; diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphLineageService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphLineageService.cs index 2ed5c8909..364abb69c 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphLineageService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphLineageService.cs @@ -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; } diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphPathService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphPathService.cs index 3b151f20c..83bd140c6 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphPathService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphPathService.cs @@ -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; diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphQueryService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphQueryService.cs index 9df02fc04..d52b1a159 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphQueryService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphQueryService.cs @@ -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; diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs index 2b32affc5..586d31edc 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphRepository.cs @@ -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; /// diff --git a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSearchService.cs b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSearchService.cs index 82a139235..bf012b7eb 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSearchService.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/InMemoryGraphSearchService.cs @@ -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; diff --git a/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRepository.cs b/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRepository.cs index 3c7590ac5..1873db045 100644 --- a/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRepository.cs +++ b/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRepository.cs @@ -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 { diff --git a/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRuntimeRepository.cs b/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRuntimeRepository.cs new file mode 100644 index 000000000..b0755f109 --- /dev/null +++ b/src/Graph/StellaOps.Graph.Api/Services/PostgresGraphRuntimeRepository.cs @@ -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 _logger; + + public PostgresGraphRuntimeRepository( + IOptions options, + TimeProvider timeProvider, + ILogger logger, + ILogger 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 Query(string tenant, GraphSearchRequest request) + => LoadTenantRepository(tenant).Query(tenant, request); + + public (IReadOnlyList Nodes, IReadOnlyList Edges) QueryGraph(string tenant, GraphQueryRequest request) + => LoadTenantRepository(tenant).QueryGraph(tenant, request); + + public (IReadOnlyList Nodes, IReadOnlyList Edges) GetLineage(string tenant, GraphLineageRequest request) + => LoadTenantRepository(tenant).GetLineage(tenant, request); + + public (IReadOnlyList Nodes, IReadOnlyList Edges)? GetSnapshot(string tenant, string snapshotId) + => GetSnapshotAsync(tenant, snapshotId).GetAwaiter().GetResult(); + + public IReadOnlyList GetCompatibilityNodes(string tenant) + => LoadTenantRepository(tenant).GetCompatibilityNodes(tenant); + + public IReadOnlyList 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 Incoming, IReadOnlyList Outgoing) GetAdjacency(string tenant, string nodeId) + => LoadTenantRepository(tenant).GetAdjacency(tenant, nodeId); + + public (IReadOnlyList Nodes, IReadOnlyList 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(), Array.Empty()); + } + } + + private async Task<(IReadOnlyList Nodes, IReadOnlyList 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 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 ParseNodeArray(string json) + { + var items = ParseSnapshotItems(json); + var nodes = new List(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 ParseEdgeArray(string json) + { + var items = ParseSnapshotItems(json); + var edges = new List(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 ParseSnapshotItems(string json) + { + var items = new List(); + + 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; + } +} diff --git a/src/Graph/StellaOps.Graph.Api/TASKS.md b/src/Graph/StellaOps.Graph.Api/TASKS.md index e497a16b6..647a7d189 100644 --- a/src/Graph/StellaOps.Graph.Api/TASKS.md +++ b/src/Graph/StellaOps.Graph.Api/TASKS.md @@ -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. | diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs index c73715d42..570ddac9a 100644 --- a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphCompatibilityEndpointsIntegrationTests.cs @@ -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 { + 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 { ["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(); + } } diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphPostgresRuntimeIntegrationTests.cs b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphPostgresRuntimeIntegrationTests.cs new file mode 100644 index 000000000..22f422ff7 --- /dev/null +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphPostgresRuntimeIntegrationTests.cs @@ -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 +{ + 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 CreateFactory() + { + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((context, configurationBuilder) => + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["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()); + 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(); + } +} diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphRuntimeRepositoryRegistrationTests.cs b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphRuntimeRepositoryRegistrationTests.cs new file mode 100644 index 000000000..0a385b6e6 --- /dev/null +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/GraphRuntimeRepositoryRegistrationTests.cs @@ -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 +{ + 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(); + + Assert.IsType(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(); + var inMemoryRepository = Assert.IsType(repository); + + Assert.Empty(inMemoryRepository.GetCompatibilityNodes("acme")); + Assert.Empty(inMemoryRepository.GetCompatibilityEdges("acme")); + } + + private static WebApplicationFactory CreateFactory(string? connectionString) + { + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("Development"); + builder.ConfigureAppConfiguration((_, configurationBuilder) => + { + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["Postgres:Graph:ConnectionString"] = connectionString, + ["Postgres:Graph:SchemaName"] = "graph", + }); + }); + }); + } +} diff --git a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/TASKS.md b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/TASKS.md index fa0a4f203..2264da49e 100644 --- a/src/Graph/__Tests/StellaOps.Graph.Api.Tests/TASKS.md +++ b/src/Graph/__Tests/StellaOps.Graph.Api.Tests/TASKS.md @@ -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. |