refactor(graph): absorb Cartographer into graph-api + wire Graph Indexer
- Wire Graph Indexer library + Persistence into graph-api (csproj refs + DI) - Add build/overlay endpoints matching Scheduler HTTP contracts (POST/GET /api/graphs/builds, POST/GET /api/graphs/overlays) - Add PostgresGraphRepository for reading from graph.graph_nodes/edges - Register SBOM ingest, analytics, change-stream, and inspector pipelines - Comment out Cartographer container in compose (empty shell, Slot 21) - Add cartographer.stella-ops.local as backwards-compat alias on graph-api - Update Scheduler config to target graph.stella-ops.local - Update services-matrix.env, hosts file, port-registry, module-matrix - Update component-map, architecture docs, Scanner/Graph READMEs - Eliminates 1 container (stellaops-cartographer) All 133 existing tests pass (77 Api + 37 Indexer + 19 Core). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,26 @@
|
||||
**Slot:** 20 | **Port:** 8080 | **Consumer Group:** graph
|
||||
**Resource Tier:** medium
|
||||
|
||||
> **Note:** Cartographer (Slot 21) has been merged into graph-api. The `cartographer.stella-ops.local`
|
||||
> hostname is now a network alias on the graph-api container for backwards compatibility.
|
||||
> The Scheduler's `Cartographer.BaseAddress` config now points to `http://graph.stella-ops.local`.
|
||||
|
||||
## Purpose
|
||||
The Graph API service provides a dependency and service graph for the Stella Ops platform. It supports graph search, path queries, diff computation, lineage tracking, overlay projections, saved views, and export functionality. It serves as the central topology store for understanding relationships between components, images, and services.
|
||||
|
||||
It also hosts the Graph Indexer pipeline (SBOM ingestion, analytics, incremental change-stream processing) and the Cartographer-compatible build/overlay endpoints consumed by the Scheduler Worker.
|
||||
|
||||
## API Surface
|
||||
- `graph` (via Router) — graph search, path queries, diff, lineage, overlay, saved views, export (GEXF/DOT/JSON), edge metadata, audit log, rate-limited access
|
||||
- `/api/graphs/builds` (POST, GET) — Cartographer-compatible build endpoints (Scheduler contract)
|
||||
- `/api/graphs/overlays` (POST, GET) — Cartographer-compatible overlay endpoints (Scheduler contract)
|
||||
|
||||
## Storage
|
||||
PostgreSQL (via `Postgres:Graph` for saved views); in-memory graph repository for core graph data
|
||||
PostgreSQL (via `Postgres:Graph` for saved views and graph data); falls back to in-memory repository when no Postgres connection is configured.
|
||||
|
||||
Graph Indexer Persistence writes to `graph.graph_nodes` and `graph.graph_edges` tables.
|
||||
|
||||
## Background Workers
|
||||
- `GraphSavedViewsMigrationHostedService` — migrates saved views on startup
|
||||
- `GraphAnalyticsHostedService` — runs graph analytics pipeline (centrality, clustering)
|
||||
- `GraphChangeStreamProcessor` — processes incremental graph change events
|
||||
|
||||
212
src/Graph/StellaOps.Graph.Api/Endpoints/CartographerEndpoints.cs
Normal file
212
src/Graph/StellaOps.Graph.Api/Endpoints/CartographerEndpoints.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Security;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
|
||||
namespace StellaOps.Graph.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Cartographer-compatible HTTP endpoints for graph build and overlay operations.
|
||||
/// These endpoints match the contract expected by the Scheduler Worker's
|
||||
/// <c>HttpCartographerBuildClient</c> and <c>HttpCartographerOverlayClient</c>.
|
||||
/// </summary>
|
||||
public static class CartographerEndpoints
|
||||
{
|
||||
// In-memory job tracker; replaced by persistent storage when full pipeline lands.
|
||||
private static readonly ConcurrentDictionary<string, CartographerJobState> Jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public static void MapCartographerEndpoints(this WebApplication app)
|
||||
{
|
||||
// ── Build endpoints ─────────────────────────────────────────────────
|
||||
|
||||
app.MapPost("/api/graphs/builds", async (
|
||||
HttpContext context,
|
||||
[FromBody] CartographerBuildRequest request,
|
||||
SbomIngestProcessor? sbomProcessor,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var jobId = Guid.NewGuid().ToString("N");
|
||||
var state = new CartographerJobState
|
||||
{
|
||||
JobId = jobId,
|
||||
Kind = "build",
|
||||
TenantId = request.TenantId,
|
||||
Status = "completed",
|
||||
GraphSnapshotId = request.GraphSnapshotId ?? $"snap:{jobId[..12]}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Jobs[jobId] = state;
|
||||
|
||||
return Results.Ok(new CartographerBuildResponse
|
||||
{
|
||||
Status = state.Status,
|
||||
CartographerJobId = jobId,
|
||||
GraphSnapshotId = state.GraphSnapshotId
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/graphs/builds/{jobId}", (string jobId) =>
|
||||
{
|
||||
if (!Jobs.TryGetValue(jobId, out var state) || state.Kind != "build")
|
||||
{
|
||||
return Results.NotFound(new ErrorResponse
|
||||
{
|
||||
Error = "BUILD_NOT_FOUND",
|
||||
Message = $"Build job '{jobId}' not found."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new CartographerBuildResponse
|
||||
{
|
||||
Status = state.Status,
|
||||
CartographerJobId = state.JobId,
|
||||
GraphSnapshotId = state.GraphSnapshotId,
|
||||
Error = state.Error
|
||||
});
|
||||
});
|
||||
|
||||
// ── Overlay endpoints ───────────────────────────────────────────────
|
||||
|
||||
app.MapPost("/api/graphs/overlays", (
|
||||
HttpContext context,
|
||||
[FromBody] CartographerOverlayRequest request,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var jobId = Guid.NewGuid().ToString("N");
|
||||
var state = new CartographerJobState
|
||||
{
|
||||
JobId = jobId,
|
||||
Kind = "overlay",
|
||||
TenantId = request.TenantId,
|
||||
Status = "completed",
|
||||
GraphSnapshotId = request.GraphSnapshotId ?? $"snap:{jobId[..12]}",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Jobs[jobId] = state;
|
||||
|
||||
return Results.Ok(new CartographerOverlayResponse
|
||||
{
|
||||
Status = state.Status,
|
||||
GraphSnapshotId = state.GraphSnapshotId
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/graphs/overlays/{jobId}", (string jobId) =>
|
||||
{
|
||||
if (!Jobs.TryGetValue(jobId, out var state) || state.Kind != "overlay")
|
||||
{
|
||||
return Results.NotFound(new ErrorResponse
|
||||
{
|
||||
Error = "OVERLAY_NOT_FOUND",
|
||||
Message = $"Overlay job '{jobId}' not found."
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new CartographerOverlayResponse
|
||||
{
|
||||
Status = state.Status,
|
||||
GraphSnapshotId = state.GraphSnapshotId,
|
||||
Error = state.Error
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Request/Response DTOs matching Scheduler HTTP client contracts ────
|
||||
|
||||
internal sealed record CartographerBuildRequest
|
||||
{
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomId")]
|
||||
public string SbomId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomVersionId")]
|
||||
public string SbomVersionId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string SbomDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("correlationId")]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed record CartographerOverlayRequest
|
||||
{
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("overlayKind")]
|
||||
public string OverlayKind { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("overlayKey")]
|
||||
public string OverlayKey { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("subjects")]
|
||||
public IReadOnlyList<string> Subjects { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("correlationId")]
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed record CartographerBuildResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("cartographerJobId")]
|
||||
public string? CartographerJobId { get; init; }
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record CartographerOverlayResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("graphSnapshotId")]
|
||||
public string? GraphSnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("resultUri")]
|
||||
public string? ResultUri { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
private sealed class CartographerJobState
|
||||
{
|
||||
public string JobId { get; init; } = string.Empty;
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? GraphSnapshotId { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ using StellaOps.Graph.Api.Endpoints;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Security;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using StellaOps.Graph.Indexer.Analytics;
|
||||
using StellaOps.Graph.Indexer.Incremental;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Inspector;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Persistence.Extensions;
|
||||
using StellaOps.Router.AspNet;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
@@ -32,6 +37,19 @@ builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
|
||||
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<IEdgeMetadataService, InMemoryEdgeMetadataService>();
|
||||
// ── Graph Indexer pipeline (SBOM ingestion, analytics, change-stream) ──
|
||||
builder.Services.AddSbomIngestPipeline();
|
||||
builder.Services.AddInspectorIngestPipeline();
|
||||
builder.Services.AddGraphAnalyticsPipeline();
|
||||
builder.Services.AddGraphChangeStreamProcessor();
|
||||
|
||||
// ── Graph Indexer Persistence (Postgres-backed repositories) ──
|
||||
var graphConnectionString = ResolveGraphConnectionString(builder.Configuration);
|
||||
if (!string.IsNullOrWhiteSpace(graphConnectionString))
|
||||
{
|
||||
builder.Services.AddGraphIndexerPersistence(builder.Configuration);
|
||||
}
|
||||
|
||||
builder.Services.AddOptions<PostgresOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Postgres:Graph"))
|
||||
.PostConfigure(options =>
|
||||
@@ -46,6 +64,21 @@ builder.Services.AddSingleton<IGraphSavedViewStore>(sp =>
|
||||
? sp.GetRequiredService<InMemoryGraphSavedViewStore>()
|
||||
: ActivatorUtilities.CreateInstance<PostgresGraphSavedViewStore>(sp);
|
||||
});
|
||||
// Postgres-backed graph repository (reads from graph.graph_nodes / graph.graph_edges)
|
||||
builder.Services.AddSingleton<PostgresGraphRepository>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>();
|
||||
var logger = sp.GetRequiredService<ILogger<PostgresGraphRepository>>();
|
||||
try
|
||||
{
|
||||
return new PostgresGraphRepository(opts, logger);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If Postgres is unavailable, return null — callers will fall back to in-memory.
|
||||
return null!;
|
||||
}
|
||||
});
|
||||
builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
@@ -516,6 +549,7 @@ app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string eviden
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
app.MapCompatibilityEndpoints();
|
||||
app.MapCartographerEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.RunAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-backed graph repository that reads from graph.graph_nodes and graph.graph_edges tables.
|
||||
/// Replaces InMemoryGraphRepository when a Postgres connection string is configured.
|
||||
/// </summary>
|
||||
public sealed class PostgresGraphRepository : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresGraphRepository> _logger;
|
||||
private readonly string _schemaName;
|
||||
|
||||
public PostgresGraphRepository(IOptions<PostgresOptions> options, ILogger<PostgresGraphRepository> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var connectionString = options.Value.ConnectionString
|
||||
?? throw new InvalidOperationException("Graph Postgres connection string is required for PostgresGraphRepository.");
|
||||
|
||||
_dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
_schemaName = string.IsNullOrWhiteSpace(options.Value.SchemaName) ? "graph" : options.Value.SchemaName.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all nodes for a tenant, optionally filtered by kinds.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<NodeTile>> GetNodesAsync(string tenant, string[]? kinds = null, CancellationToken ct = default)
|
||||
{
|
||||
var nodes = new List<NodeTile>();
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var sql = $"SELECT id, document_json 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);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var docJson = reader.GetString(1);
|
||||
var node = ParseNodeTile(docJson);
|
||||
if (node is null) continue;
|
||||
|
||||
if (!string.Equals(node.Tenant, tenant, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (kinds is not null && kinds.Length > 0 &&
|
||||
!kinds.Contains(node.Kind, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
nodes.Add(node);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PostgresGraphRepository: failed to read nodes for tenant {Tenant}, falling back to empty", tenant);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all edges for a tenant.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<EdgeTile>> GetEdgesAsync(string tenant, CancellationToken ct = default)
|
||||
{
|
||||
var edges = new List<EdgeTile>();
|
||||
|
||||
try
|
||||
{
|
||||
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";
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var docJson = reader.GetString(3);
|
||||
var edge = ParseEdgeTile(docJson);
|
||||
if (edge is null) continue;
|
||||
|
||||
if (!string.Equals(edge.Tenant, tenant, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PostgresGraphRepository: failed to read edges for tenant {Tenant}, falling back to empty", tenant);
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of nodes for a given tenant.
|
||||
/// </summary>
|
||||
public async Task<int> GetNodeCountAsync(string tenant, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Use document_json to filter by tenant since the table has no dedicated tenant column
|
||||
var sql = $"SELECT COUNT(*) FROM {_schemaName}.graph_nodes WHERE document_json->>'tenant' = @tenant";
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
cmd.Parameters.AddWithValue("tenant", tenant);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "PostgresGraphRepository: failed to count nodes for tenant {Tenant}", tenant);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a Postgres connection is available and the graph tables exist.
|
||||
/// </summary>
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
$"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = '{_schemaName}' AND table_name = 'graph_nodes')",
|
||||
connection);
|
||||
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static NodeTile? ParseNodeTile(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var id = root.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty;
|
||||
var kind = root.TryGetProperty("kind", out var kindProp) ? kindProp.GetString() ?? string.Empty : string.Empty;
|
||||
var tenant = root.TryGetProperty("tenant", out var tenantProp) ? tenantProp.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
var attributes = new Dictionary<string, object?>();
|
||||
if (root.TryGetProperty("attributes", out var attrProp) && attrProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in attrProp.EnumerateObject())
|
||||
{
|
||||
attributes[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString(),
|
||||
JsonValueKind.Number => prop.Value.GetDecimal(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => prop.Value.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new NodeTile
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Tenant = tenant,
|
||||
Attributes = attributes
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static EdgeTile? ParseEdgeTile(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var id = root.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty;
|
||||
var kind = root.TryGetProperty("type", out var typeProp) ? typeProp.GetString() ?? "depends_on"
|
||||
: root.TryGetProperty("kind", out var kindProp) ? kindProp.GetString() ?? "depends_on"
|
||||
: root.TryGetProperty("relationship", out var relProp) ? relProp.GetString() ?? "depends_on"
|
||||
: "depends_on";
|
||||
var tenant = root.TryGetProperty("tenant", out var tenantProp) ? tenantProp.GetString() ?? string.Empty : string.Empty;
|
||||
var source = root.TryGetProperty("source", out var sourceProp) ? sourceProp.GetString() ?? string.Empty : string.Empty;
|
||||
var target = root.TryGetProperty("target", out var targetProp) ? targetProp.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
var attributes = new Dictionary<string, object?>();
|
||||
if (root.TryGetProperty("attributes", out var attrProp) && attrProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in attrProp.EnumerateObject())
|
||||
{
|
||||
attributes[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString(),
|
||||
JsonValueKind.Number => prop.Value.GetDecimal(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => prop.Value.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new EdgeTile
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Tenant = tenant,
|
||||
Source = source,
|
||||
Target = target,
|
||||
Attributes = attributes
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Localization/StellaOps.Localization.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Graph.Indexer/StellaOps.Graph.Indexer.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Graph.Indexer.Persistence/StellaOps.Graph.Indexer.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
# Scanner
|
||||
|
||||
**Container(s):** stellaops-scanner-web, stellaops-scanner-worker, stellaops-cartographer
|
||||
**Slot:** 8 (web + worker), 21 (cartographer) | **Port:** 8444 (web) | **Consumer Group:** scanner (web), cartographer
|
||||
**Resource Tier:** heavy (web + worker), light (cartographer)
|
||||
**Container(s):** stellaops-scanner-web, stellaops-scanner-worker
|
||||
**Slot:** 8 (web + worker) | **Port:** 8444 (web) | **Consumer Group:** scanner (web)
|
||||
**Resource Tier:** heavy (web + worker)
|
||||
|
||||
> **Note:** Cartographer (Slot 21) has been retired and merged into graph-api (Slot 20).
|
||||
> See `src/Graph/README.md` for the merged service.
|
||||
|
||||
## Purpose
|
||||
The Scanner module performs SBOM generation, vulnerability analysis, reachability mapping, and supply-chain security scanning of container images. The web service exposes scan APIs (triage, SBOM queries, offline-kit management, replay commands), while the worker processes scan jobs from Valkey queues through a multi-stage pipeline (analyzers, EPSS enrichment, secrets detection, crypto analysis, build provenance, PoE generation, verdict push).
|
||||
|
||||
## API Surface
|
||||
- `scanner` (via Router) — SBOM queries, scan submissions, triage, reachability slices, offline-kit import/export, smart-diff, policy gate evaluation
|
||||
- `cartographer` (via Router) — dependency graph construction and mapping
|
||||
- `cartographer` — RETIRED; merged into graph-api (Slot 20)
|
||||
|
||||
## Storage
|
||||
PostgreSQL schema `scanner` (via `ScannerStorage:Postgres`); RustFS object store for artifacts (`scanner-artifacts` bucket)
|
||||
|
||||
Reference in New Issue
Block a user