up
This commit is contained in:
@@ -23,6 +23,36 @@ public record GraphSearchRequest
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
public record GraphQueryRequest
|
||||
{
|
||||
[JsonPropertyName("kinds")]
|
||||
public string[] Kinds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string? Query { get; init; }
|
||||
|
||||
[JsonPropertyName("filters")]
|
||||
public Dictionary<string, object>? Filters { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int? Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("cursor")]
|
||||
public string? Cursor { get; init; }
|
||||
|
||||
[JsonPropertyName("includeEdges")]
|
||||
public bool IncludeEdges { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("includeStats")]
|
||||
public bool IncludeStats { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("includeOverlays")]
|
||||
public bool IncludeOverlays { get; init; } = false;
|
||||
|
||||
[JsonPropertyName("budget")]
|
||||
public GraphQueryBudget? Budget { get; init; }
|
||||
}
|
||||
|
||||
public static class SearchValidator
|
||||
{
|
||||
public static string? Validate(GraphSearchRequest req)
|
||||
@@ -51,6 +81,234 @@ public static class SearchValidator
|
||||
}
|
||||
}
|
||||
|
||||
public static class QueryValidator
|
||||
{
|
||||
public static string? Validate(GraphQueryRequest req)
|
||||
{
|
||||
if (req.Kinds is null || req.Kinds.Length == 0)
|
||||
{
|
||||
return "kinds is required";
|
||||
}
|
||||
|
||||
if (req.Limit.HasValue && (req.Limit.Value <= 0 || req.Limit.Value > 500))
|
||||
{
|
||||
return "limit must be between 1 and 500";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(req.Query) && (req.Filters is null || req.Filters.Count == 0) && string.IsNullOrWhiteSpace(req.Cursor))
|
||||
{
|
||||
return "query or filters or cursor must be provided";
|
||||
}
|
||||
|
||||
if (req.Budget is not null)
|
||||
{
|
||||
if (req.Budget.Tiles.HasValue && (req.Budget.Tiles < 1 || req.Budget.Tiles > 6000))
|
||||
{
|
||||
return "budget.tiles must be between 1 and 5000";
|
||||
}
|
||||
|
||||
if (req.Budget.Nodes.HasValue && req.Budget.Nodes < 1)
|
||||
{
|
||||
return "budget.nodes must be >= 1";
|
||||
}
|
||||
|
||||
if (req.Budget.Edges.HasValue && req.Budget.Edges < 1)
|
||||
{
|
||||
return "budget.edges must be >= 1";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record GraphExportRequest
|
||||
{
|
||||
[JsonPropertyName("format")]
|
||||
public string Format { get; init; } = "ndjson"; // ndjson, csv, graphml, png, svg
|
||||
|
||||
[JsonPropertyName("includeEdges")]
|
||||
public bool IncludeEdges { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("snapshotId")]
|
||||
public string? SnapshotId { get; init; }
|
||||
|
||||
[JsonPropertyName("kinds")]
|
||||
public string[]? Kinds { get; init; }
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public string? Query { get; init; }
|
||||
|
||||
[JsonPropertyName("filters")]
|
||||
public Dictionary<string, object>? Filters { get; init; }
|
||||
}
|
||||
|
||||
public static class ExportValidator
|
||||
{
|
||||
private static readonly HashSet<string> SupportedFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ndjson", "csv", "graphml", "png", "svg"
|
||||
};
|
||||
|
||||
public static string? Validate(GraphExportRequest req)
|
||||
{
|
||||
if (!SupportedFormats.Contains(req.Format))
|
||||
{
|
||||
return "format must be one of ndjson,csv,graphml,png,svg";
|
||||
}
|
||||
|
||||
if (req.Kinds is not null && req.Kinds.Length == 0)
|
||||
{
|
||||
return "kinds cannot be empty array";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record GraphPathRequest
|
||||
{
|
||||
[JsonPropertyName("sources")]
|
||||
public string[] Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("targets")]
|
||||
public string[] Targets { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("kinds")]
|
||||
public string[] Kinds { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("maxDepth")]
|
||||
public int? MaxDepth { get; init; }
|
||||
|
||||
[JsonPropertyName("filters")]
|
||||
public Dictionary<string, object>? Filters { get; init; }
|
||||
|
||||
[JsonPropertyName("includeOverlays")]
|
||||
public bool IncludeOverlays { get; init; } = false;
|
||||
|
||||
[JsonPropertyName("budget")]
|
||||
public GraphQueryBudget? Budget { get; init; }
|
||||
}
|
||||
|
||||
public static class PathValidator
|
||||
{
|
||||
public static string? Validate(GraphPathRequest req)
|
||||
{
|
||||
if (req.Sources is null || req.Sources.Length == 0)
|
||||
{
|
||||
return "sources is required";
|
||||
}
|
||||
|
||||
if (req.Targets is null || req.Targets.Length == 0)
|
||||
{
|
||||
return "targets is required";
|
||||
}
|
||||
|
||||
if (req.MaxDepth.HasValue && (req.MaxDepth.Value < 1 || req.MaxDepth.Value > 6))
|
||||
{
|
||||
return "maxDepth must be between 1 and 6";
|
||||
}
|
||||
|
||||
if (req.Budget is not null)
|
||||
{
|
||||
if (req.Budget.Tiles.HasValue && (req.Budget.Tiles < 1 || req.Budget.Tiles > 6000))
|
||||
{
|
||||
return "budget.tiles must be between 1 and 6000";
|
||||
}
|
||||
|
||||
if (req.Budget.Nodes.HasValue && req.Budget.Nodes < 1)
|
||||
{
|
||||
return "budget.nodes must be >= 1";
|
||||
}
|
||||
|
||||
if (req.Budget.Edges.HasValue && req.Budget.Edges < 1)
|
||||
{
|
||||
return "budget.edges must be >= 1";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record GraphDiffRequest
|
||||
{
|
||||
[JsonPropertyName("snapshotA")]
|
||||
public string SnapshotA { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("snapshotB")]
|
||||
public string SnapshotB { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("includeEdges")]
|
||||
public bool IncludeEdges { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("includeStats")]
|
||||
public bool IncludeStats { get; init; } = true;
|
||||
|
||||
[JsonPropertyName("budget")]
|
||||
public GraphQueryBudget? Budget { get; init; }
|
||||
}
|
||||
|
||||
public static class DiffValidator
|
||||
{
|
||||
public static string? Validate(GraphDiffRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.SnapshotA))
|
||||
{
|
||||
return "snapshotA is required";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(req.SnapshotB))
|
||||
{
|
||||
return "snapshotB is required";
|
||||
}
|
||||
|
||||
if (req.Budget is not null)
|
||||
{
|
||||
if (req.Budget.Tiles.HasValue && (req.Budget.Tiles < 1 || req.Budget.Tiles > 6000))
|
||||
{
|
||||
return "budget.tiles must be between 1 and 6000";
|
||||
}
|
||||
|
||||
if (req.Budget.Nodes.HasValue && req.Budget.Nodes < 1)
|
||||
{
|
||||
return "budget.nodes must be >= 1";
|
||||
}
|
||||
|
||||
if (req.Budget.Edges.HasValue && req.Budget.Edges < 1)
|
||||
{
|
||||
return "budget.edges must be >= 1";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record GraphQueryBudget
|
||||
{
|
||||
[JsonPropertyName("tiles")]
|
||||
public int? Tiles { get; init; }
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public int? Nodes { get; init; }
|
||||
|
||||
[JsonPropertyName("edges")]
|
||||
public int? Edges { get; init; }
|
||||
|
||||
public GraphQueryBudget ApplyDefaults()
|
||||
{
|
||||
return new GraphQueryBudget
|
||||
{
|
||||
Tiles = Tiles ?? 6000,
|
||||
Nodes = Nodes ?? 5000,
|
||||
Edges = Edges ?? 10000
|
||||
};
|
||||
}
|
||||
|
||||
public static GraphQueryBudget Default { get; } = new();
|
||||
}
|
||||
|
||||
public record CostBudget(int Limit, int Remaining, int Consumed);
|
||||
|
||||
public record NodeTile
|
||||
@@ -63,6 +321,22 @@ public record NodeTile
|
||||
public Dictionary<string, OverlayPayload>? Overlays { get; init; }
|
||||
}
|
||||
|
||||
public record EdgeTile
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Kind { get; init; } = "depends_on";
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string Target { get; init; } = string.Empty;
|
||||
public Dictionary<string, object?> Attributes { get; init; } = new();
|
||||
}
|
||||
|
||||
public record StatsTile
|
||||
{
|
||||
public int Nodes { get; init; }
|
||||
public int Edges { get; init; }
|
||||
}
|
||||
|
||||
public record CursorTile(string Token, string ResumeUrl);
|
||||
|
||||
public record TileEnvelope(string Type, int Seq, object Data, CostBudget? Cost = null);
|
||||
@@ -76,3 +350,22 @@ public record ErrorResponse
|
||||
public object? Details { get; init; }
|
||||
public string? RequestId { get; init; }
|
||||
}
|
||||
|
||||
public record DiffTile
|
||||
{
|
||||
public string EntityType { get; init; } = string.Empty;
|
||||
public string ChangeType { get; init; } = string.Empty;
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public object? Before { get; init; }
|
||||
public object? After { get; init; }
|
||||
}
|
||||
|
||||
public record DiffStatsTile
|
||||
{
|
||||
public int NodesAdded { get; init; }
|
||||
public int NodesRemoved { get; init; }
|
||||
public int NodesChanged { get; init; }
|
||||
public int EdgesAdded { get; init; }
|
||||
public int EdgesRemoved { get; init; }
|
||||
public int EdgesChanged { get; init; }
|
||||
}
|
||||
|
||||
19
src/Graph/StellaOps.Graph.Api/Deploy/HEALTH.md
Normal file
19
src/Graph/StellaOps.Graph.Api/Deploy/HEALTH.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Graph API Deploy Health Checks
|
||||
|
||||
- **Readiness**: `GET /healthz` on port 8080
|
||||
- **Liveness**: `GET /healthz` on port 8080
|
||||
- Expected latency: < 200ms on local/dev.
|
||||
- Failing conditions:
|
||||
- Missing `X-Stella-Tenant` header on app routes returns 400 but healthz remains 200.
|
||||
- Rate limiting does not apply to `/healthz`.
|
||||
|
||||
Smoke test (once deployed):
|
||||
```bash
|
||||
curl -i http://localhost:8080/healthz
|
||||
curl -i -X POST http://localhost:8080/graph/search \
|
||||
-H "X-Stella-Tenant: demo" \
|
||||
-H "X-Stella-Scopes: graph:read graph:query" \
|
||||
-H "Authorization: bearer demo" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"kinds":["component"],"query":"pkg:"}'
|
||||
```
|
||||
18
src/Graph/StellaOps.Graph.Api/Deploy/docker-compose.yaml
Normal file
18
src/Graph/StellaOps.Graph.Api/Deploy/docker-compose.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
graph-api:
|
||||
image: stellaops/graph-api:latest
|
||||
container_name: stellaops-graph-api
|
||||
environment:
|
||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||
STELLAOPS_GRAPH_SNAPSHOT_DIR: "/data/snapshots"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data/snapshots:/data/snapshots
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
85
src/Graph/StellaOps.Graph.Api/Deploy/kubernetes.yaml
Normal file
85
src/Graph/StellaOps.Graph.Api/Deploy/kubernetes.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: stellaops-graph-api
|
||||
labels:
|
||||
app: stellaops-graph-api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stellaops-graph-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stellaops-graph-api
|
||||
spec:
|
||||
containers:
|
||||
- name: graph-api
|
||||
image: stellaops/graph-api:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: ASPNETCORE_URLS
|
||||
value: http://0.0.0.0:8080
|
||||
- name: STELLAOPS_GRAPH_SNAPSHOT_DIR
|
||||
value: /var/lib/stellaops/graph/snapshots
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: snapshots
|
||||
mountPath: /var/lib/stellaops/graph/snapshots
|
||||
volumes:
|
||||
- name: snapshots
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: stellaops-graph-api
|
||||
labels:
|
||||
app: stellaops-graph-api
|
||||
spec:
|
||||
selector:
|
||||
app: stellaops-graph-api
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: stellaops-graph-api
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "25m"
|
||||
spec:
|
||||
rules:
|
||||
- http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: stellaops-graph-api
|
||||
port:
|
||||
number: 80
|
||||
@@ -3,14 +3,24 @@ using StellaOps.Graph.Api.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<InMemoryGraphRepository>();
|
||||
builder.Services.AddSingleton<IGraphSearchService, InMemoryGraphSearchService>();
|
||||
builder.Services.AddScoped<IGraphSearchService, InMemoryGraphSearchService>();
|
||||
builder.Services.AddScoped<IGraphQueryService, InMemoryGraphQueryService>();
|
||||
builder.Services.AddScoped<IGraphPathService, InMemoryGraphPathService>();
|
||||
builder.Services.AddScoped<IGraphDiffService, InMemoryGraphDiffService>();
|
||||
builder.Services.AddScoped<IOverlayService, InMemoryOverlayService>();
|
||||
builder.Services.AddScoped<IGraphExportService, InMemoryGraphExportService>();
|
||||
builder.Services.AddSingleton<IRateLimiter>(_ => new RateLimiterService(limitPerWindow: 120));
|
||||
builder.Services.AddSingleton<IAuditLogger, InMemoryAuditLogger>();
|
||||
builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest request, IGraphSearchService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
@@ -25,10 +35,28 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/search"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/search", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:read") && !scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:read or graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var validation = SearchValidator.Validate(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
|
||||
LogAudit(context, "/graph/search", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
@@ -38,10 +66,242 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
await context.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
LogAudit(context, "/graph/search", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest request, IGraphQueryService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/query", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var validation = QueryValidator.Validate(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
|
||||
LogAudit(context, "/graph/query", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.QueryAsync(tenant!, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
await context.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
LogAudit(context, "/graph/query", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request, IGraphPathService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/paths"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/paths", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var validation = PathValidator.Validate(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
|
||||
LogAudit(context, "/graph/paths", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.FindPathsAsync(tenant!, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
await context.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
LogAudit(context, "/graph/paths", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request, IGraphDiffService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
context.Response.ContentType = "application/x-ndjson";
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/diff"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/diff", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:query"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:query scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var validation = DiffValidator.Validate(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
|
||||
LogAudit(context, "/graph/diff", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.DiffAsync(tenant!, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
await context.Response.Body.FlushAsync(ct);
|
||||
}
|
||||
LogAudit(context, "/graph/diff", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
|
||||
return Results.Empty;
|
||||
});
|
||||
|
||||
app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest request, IGraphExportService service, CancellationToken ct) =>
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var tenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", "Missing X-Stella-Tenant header", ct);
|
||||
LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.ContainsKey("Authorization"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status401Unauthorized, "GRAPH_UNAUTHORIZED", "Missing Authorization header", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var scopes = context.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!scopes.Contains("graph:export"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status403Forbidden, "GRAPH_FORBIDDEN", "Missing graph:export scope", ct);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
if (!RateLimit(context, "/graph/export"))
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status429TooManyRequests, "GRAPH_RATE_LIMITED", "Too many requests", ct);
|
||||
LogAudit(context, "/graph/export", StatusCodes.Status429TooManyRequests, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var validation = ExportValidator.Validate(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
await WriteError(context, StatusCodes.Status400BadRequest, "GRAPH_VALIDATION_FAILED", validation, ct);
|
||||
LogAudit(context, "/graph/export", StatusCodes.Status400BadRequest, sw.ElapsedMilliseconds);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var job = await service.StartExportAsync(tenant!, request, ct);
|
||||
var manifest = new
|
||||
{
|
||||
jobId = job.JobId,
|
||||
status = "completed",
|
||||
format = job.Format,
|
||||
sha256 = job.Sha256,
|
||||
size = job.SizeBytes,
|
||||
downloadUrl = $"/graph/export/{job.JobId}",
|
||||
completedAt = job.CompletedAt
|
||||
};
|
||||
LogAudit(context, "/graph/export", StatusCodes.Status200OK, sw.ElapsedMilliseconds);
|
||||
return Results.Ok(manifest);
|
||||
});
|
||||
|
||||
app.MapGet("/graph/export/{jobId}", (string jobId, HttpContext context, IGraphExportService service) =>
|
||||
{
|
||||
var job = service.Get(jobId);
|
||||
if (job is null)
|
||||
{
|
||||
return Results.NotFound(new ErrorResponse { Error = "GRAPH_EXPORT_NOT_FOUND", Message = "Export job not found" });
|
||||
}
|
||||
|
||||
context.Response.Headers.ContentLength = job.Payload.Length;
|
||||
context.Response.Headers["X-Content-SHA256"] = job.Sha256;
|
||||
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
|
||||
});
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task WriteError(HttpContext ctx, int status, string code, string message, CancellationToken ct)
|
||||
@@ -54,3 +314,30 @@ static async Task WriteError(HttpContext ctx, int status, string code, string me
|
||||
});
|
||||
await ctx.Response.WriteAsync(payload + "\n", ct);
|
||||
}
|
||||
|
||||
static bool RateLimit(HttpContext ctx, string route)
|
||||
{
|
||||
var limiter = ctx.RequestServices.GetRequiredService<IRateLimiter>();
|
||||
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
|
||||
return limiter.Allow(tenant, route);
|
||||
}
|
||||
|
||||
static void LogAudit(HttpContext ctx, string route, int statusCode, long durationMs)
|
||||
{
|
||||
var logger = ctx.RequestServices.GetRequiredService<IAuditLogger>();
|
||||
var tenant = ctx.Request.Headers["X-Stella-Tenant"].FirstOrDefault() ?? "unknown";
|
||||
var actor = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? "anonymous";
|
||||
var scopes = ctx.Request.Headers["X-Stella-Scopes"]
|
||||
.SelectMany(v => v.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.ToArray();
|
||||
|
||||
logger.Log(new AuditEvent(
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
Tenant: tenant,
|
||||
Route: route,
|
||||
Method: ctx.Request.Method,
|
||||
Actor: actor,
|
||||
Scopes: scopes,
|
||||
StatusCode: statusCode,
|
||||
DurationMs: durationMs));
|
||||
}
|
||||
|
||||
40
src/Graph/StellaOps.Graph.Api/Services/GraphMetrics.cs
Normal file
40
src/Graph/StellaOps.Graph.Api/Services/GraphMetrics.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public interface IGraphMetrics : IDisposable
|
||||
{
|
||||
Counter<long> BudgetDenied { get; }
|
||||
Histogram<double> QueryLatencySeconds { get; }
|
||||
Counter<long> OverlayCacheHit { get; }
|
||||
Counter<long> OverlayCacheMiss { get; }
|
||||
Histogram<double> ExportLatencySeconds { get; }
|
||||
Meter Meter { get; }
|
||||
}
|
||||
|
||||
public sealed class GraphMetrics : IGraphMetrics
|
||||
{
|
||||
private readonly Meter _meter;
|
||||
|
||||
public GraphMetrics()
|
||||
{
|
||||
_meter = new Meter("StellaOps.Graph.Api", "1.0.0");
|
||||
BudgetDenied = _meter.CreateCounter<long>("graph_query_budget_denied_total");
|
||||
QueryLatencySeconds = _meter.CreateHistogram<double>("graph_tile_latency_seconds", unit: "s");
|
||||
OverlayCacheHit = _meter.CreateCounter<long>("graph_overlay_cache_hits_total");
|
||||
OverlayCacheMiss = _meter.CreateCounter<long>("graph_overlay_cache_misses_total");
|
||||
ExportLatencySeconds = _meter.CreateHistogram<double>("graph_export_latency_seconds", unit: "s");
|
||||
}
|
||||
|
||||
public Counter<long> BudgetDenied { get; }
|
||||
public Histogram<double> QueryLatencySeconds { get; }
|
||||
public Counter<long> OverlayCacheHit { get; }
|
||||
public Counter<long> OverlayCacheMiss { get; }
|
||||
public Histogram<double> ExportLatencySeconds { get; }
|
||||
public Meter Meter => _meter;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_meter.Dispose();
|
||||
}
|
||||
}
|
||||
44
src/Graph/StellaOps.Graph.Api/Services/IAuditLogger.cs
Normal file
44
src/Graph/StellaOps.Graph.Api/Services/IAuditLogger.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public record AuditEvent(
|
||||
DateTimeOffset Timestamp,
|
||||
string Tenant,
|
||||
string Route,
|
||||
string Method,
|
||||
string Actor,
|
||||
string[] Scopes,
|
||||
int StatusCode,
|
||||
long DurationMs);
|
||||
|
||||
public interface IAuditLogger
|
||||
{
|
||||
void Log(AuditEvent evt);
|
||||
IReadOnlyList<AuditEvent> GetRecent(int max = 100);
|
||||
}
|
||||
|
||||
public sealed class InMemoryAuditLogger : IAuditLogger
|
||||
{
|
||||
private readonly LinkedList<AuditEvent> _events = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public void Log(AuditEvent evt)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_events.AddFirst(evt);
|
||||
while (_events.Count > 500)
|
||||
{
|
||||
_events.RemoveLast();
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[AUDIT] {evt.Timestamp:O} tenant={evt.Tenant} route={evt.Route} status={evt.StatusCode} scopes={string.Join(' ', evt.Scopes)} duration_ms={evt.DurationMs}");
|
||||
}
|
||||
|
||||
public IReadOnlyList<AuditEvent> GetRecent(int max = 100)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _events.Take(max).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public interface IGraphDiffService
|
||||
{
|
||||
IAsyncEnumerable<string> DiffAsync(string tenant, GraphDiffRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public record GraphExportJob(string JobId, string Tenant, string Format, string ContentType, byte[] Payload, string Sha256, long SizeBytes, DateTimeOffset CompletedAt);
|
||||
|
||||
public interface IGraphExportService
|
||||
{
|
||||
Task<GraphExportJob> StartExportAsync(string tenant, GraphExportRequest request, CancellationToken ct = default);
|
||||
GraphExportJob? Get(string jobId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public interface IGraphPathService
|
||||
{
|
||||
IAsyncEnumerable<string> FindPathsAsync(string tenant, GraphPathRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public interface IGraphQueryService
|
||||
{
|
||||
IAsyncEnumerable<string> QueryAsync(string tenant, GraphQueryRequest request, CancellationToken ct = default);
|
||||
}
|
||||
12
src/Graph/StellaOps.Graph.Api/Services/IOverlayService.cs
Normal file
12
src/Graph/StellaOps.Graph.Api/Services/IOverlayService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public interface IOverlayService
|
||||
{
|
||||
Task<IDictionary<string, Dictionary<string, OverlayPayload>>> GetOverlaysAsync(
|
||||
string tenant,
|
||||
IEnumerable<string> nodeIds,
|
||||
bool sampleExplain,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphDiffService(InMemoryGraphRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> DiffAsync(string tenant, GraphDiffRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults();
|
||||
var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000);
|
||||
var nodeBudgetRemaining = budget.Nodes ?? 5000;
|
||||
var edgeBudgetRemaining = budget.Edges ?? 10000;
|
||||
var budgetRemaining = tileBudgetLimit;
|
||||
var seq = 0;
|
||||
|
||||
var snapA = _repository.GetSnapshot(tenant, request.SnapshotA);
|
||||
var snapB = _repository.GetSnapshot(tenant, request.SnapshotB);
|
||||
|
||||
if (snapA is null || snapB is null)
|
||||
{
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_SNAPSHOT_NOT_FOUND",
|
||||
Message = "One or both snapshots are missing.",
|
||||
Details = new { request.SnapshotA, request.SnapshotB }
|
||||
};
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var nodesA = snapA.Value.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var nodesB = snapB.Value.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var edgesA = snapA.Value.Edges.ToDictionary(e => e.Id, StringComparer.Ordinal);
|
||||
var edgesB = snapB.Value.Edges.ToDictionary(e => e.Id, StringComparer.Ordinal);
|
||||
|
||||
foreach (var added in nodesB.Values.Where(n => !nodesA.ContainsKey(n.Id)).OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("node_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
foreach (var removed in nodesA.Values.Where(n => !nodesB.ContainsKey(n.Id)).OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("node_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
foreach (var common in nodesA.Keys.Intersect(nodesB.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var a = nodesA[common];
|
||||
var b = nodesB[common];
|
||||
if (!AttributesEqual(a.Attributes, b.Attributes))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "node",
|
||||
ChangeType = "changed",
|
||||
Id = common,
|
||||
Before = a,
|
||||
After = b
|
||||
};
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("node_changed", seq++, diff, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.IncludeEdges)
|
||||
{
|
||||
foreach (var added in edgesB.Values.Where(e => !edgesA.ContainsKey(e.Id)).OrderBy(e => e.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("edge_added", seq++, added, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
foreach (var removed in edgesA.Values.Where(e => !edgesB.ContainsKey(e.Id)).OrderBy(e => e.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("edge_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
foreach (var common in edgesA.Keys.Intersect(edgesB.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var a = edgesA[common];
|
||||
var b = edgesB[common];
|
||||
if (!AttributesEqual(a.Attributes, b.Attributes))
|
||||
{
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "edge",
|
||||
ChangeType = "changed",
|
||||
Id = common,
|
||||
Before = a,
|
||||
After = b
|
||||
};
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("edge_changed", seq++, diff, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.IncludeStats && budgetRemaining > 0)
|
||||
{
|
||||
var stats = new DiffStatsTile
|
||||
{
|
||||
NodesAdded = nodesB.Count(n => !nodesA.ContainsKey(n.Key)),
|
||||
NodesRemoved = nodesA.Count(n => !nodesB.ContainsKey(n.Key)),
|
||||
NodesChanged = nodesA.Keys.Intersect(nodesB.Keys, StringComparer.Ordinal).Count(id => !AttributesEqual(nodesA[id].Attributes, nodesB[id].Attributes)),
|
||||
EdgesAdded = request.IncludeEdges ? edgesB.Count(e => !edgesA.ContainsKey(e.Key)) : 0,
|
||||
EdgesRemoved = request.IncludeEdges ? edgesA.Count(e => !edgesB.ContainsKey(e.Key)) : 0,
|
||||
EdgesChanged = request.IncludeEdges ? edgesA.Keys.Intersect(edgesB.Keys, StringComparer.Ordinal).Count(id => !AttributesEqual(edgesA[id].Attributes, edgesB[id].Attributes)) : 0
|
||||
};
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool Spend(ref int budgetRemaining, ref int entityBudget, int limit, int seq, out string? tile)
|
||||
{
|
||||
if (budgetRemaining <= 0 || entityBudget <= 0)
|
||||
{
|
||||
tile = JsonSerializer.Serialize(new TileEnvelope("error", seq, new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_BUDGET_EXCEEDED",
|
||||
Message = "Diff exceeded budget."
|
||||
}, Cost(limit, budgetRemaining)), Options);
|
||||
return false;
|
||||
}
|
||||
|
||||
budgetRemaining--;
|
||||
entityBudget--;
|
||||
tile = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(IDictionary<string, object?> a, IDictionary<string, object?> b)
|
||||
{
|
||||
if (a.Count != b.Count) return false;
|
||||
foreach (var kvp in a)
|
||||
{
|
||||
if (!b.TryGetValue(kvp.Key, out var other)) return false;
|
||||
if (!(kvp.Value?.ToString() ?? string.Empty).Equals(other?.ToString() ?? string.Empty, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CostBudget Cost(int limit, int remaining) =>
|
||||
new(limit, remaining - 1, limit - (remaining - 1));
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphExportService : IGraphExportService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private readonly Dictionary<string, GraphExportJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public InMemoryGraphExportService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<GraphExportJob> StartExportAsync(string tenant, GraphExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// For now exports complete synchronously; job model kept for future async workers.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var (nodes, edges) = ResolveGraph(tenant, request);
|
||||
var (payload, contentType) = request.Format.ToLowerInvariant() switch
|
||||
{
|
||||
"ndjson" => (ExportNdjson(nodes, edges, request.IncludeEdges), "application/x-ndjson"),
|
||||
"csv" => (ExportCsv(nodes, edges, request.IncludeEdges), "text/csv"),
|
||||
"graphml" => (ExportGraphml(nodes, edges, request.IncludeEdges), "application/graphml+xml"),
|
||||
"png" => (ExportPlaceholder("png"), "image/png"),
|
||||
"svg" => (ExportPlaceholder("svg"), "image/svg+xml"),
|
||||
_ => (ExportNdjson(nodes, edges, request.IncludeEdges), "application/x-ndjson")
|
||||
};
|
||||
|
||||
var sha = ComputeSha256(payload);
|
||||
var jobId = $"job-{Guid.NewGuid():N}";
|
||||
var job = new GraphExportJob(jobId, tenant, request.Format, contentType, payload, sha, payload.Length, DateTimeOffset.UtcNow);
|
||||
_jobs[jobId] = job;
|
||||
sw.Stop();
|
||||
_metrics.ExportLatencySeconds.Record(sw.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("format", request.Format));
|
||||
await Task.CompletedTask;
|
||||
return job;
|
||||
}
|
||||
|
||||
public GraphExportJob? Get(string jobId)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return job;
|
||||
}
|
||||
|
||||
private (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) ResolveGraph(string tenant, GraphExportRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.SnapshotId))
|
||||
{
|
||||
var snap = _repository.GetSnapshot(tenant, request.SnapshotId!);
|
||||
if (snap is not null) return snap.Value;
|
||||
}
|
||||
|
||||
var graphReq = new GraphQueryRequest
|
||||
{
|
||||
Kinds = request.Kinds ?? Array.Empty<string>(),
|
||||
Query = request.Query,
|
||||
Filters = request.Filters,
|
||||
IncludeEdges = request.IncludeEdges,
|
||||
Limit = 5000 // bounded export for in-memory demo
|
||||
};
|
||||
var (nodes, edges) = _repository.QueryGraph(tenant, graphReq);
|
||||
return (nodes, edges);
|
||||
}
|
||||
|
||||
private static byte[] ExportNdjson(IReadOnlyList<NodeTile> nodes, IReadOnlyList<EdgeTile> edges, bool includeEdges)
|
||||
{
|
||||
var lines = new List<string>(nodes.Count + (includeEdges ? edges.Count : 0));
|
||||
foreach (var n in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
lines.Add(System.Text.Json.JsonSerializer.Serialize(new { type = "node", data = n }, GraphQueryJson.Options));
|
||||
}
|
||||
if (includeEdges)
|
||||
{
|
||||
foreach (var e in edges.OrderBy(e => e.Id, StringComparer.Ordinal))
|
||||
{
|
||||
lines.Add(System.Text.Json.JsonSerializer.Serialize(new { type = "edge", data = e }, GraphQueryJson.Options));
|
||||
}
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(string.Join("\n", lines));
|
||||
}
|
||||
|
||||
private static byte[] ExportCsv(IReadOnlyList<NodeTile> nodes, IReadOnlyList<EdgeTile> edges, bool includeEdges)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("type,id,kind,tenant,source,target");
|
||||
foreach (var n in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"node,\"{n.Id}\",{n.Kind},{n.Tenant},,");
|
||||
}
|
||||
if (includeEdges)
|
||||
{
|
||||
foreach (var e in edges.OrderBy(e => e.Id, StringComparer.Ordinal))
|
||||
{
|
||||
sb.AppendLine($"edge,\"{e.Id}\",{e.Kind},{e.Tenant},\"{e.Source}\",\"{e.Target}\"");
|
||||
}
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
|
||||
private static byte[] ExportGraphml(IReadOnlyList<NodeTile> nodes, IReadOnlyList<EdgeTile> edges, bool includeEdges)
|
||||
{
|
||||
XNamespace ns = "http://graphml.graphdrawing.org/xmlns";
|
||||
var g = new XElement(ns + "graph",
|
||||
new XAttribute("id", "g0"),
|
||||
new XAttribute("edgedefault", "directed"));
|
||||
|
||||
foreach (var n in nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
g.Add(new XElement(ns + "node", new XAttribute("id", n.Id)));
|
||||
}
|
||||
|
||||
if (includeEdges)
|
||||
{
|
||||
foreach (var e in edges.OrderBy(e => e.Id, StringComparer.Ordinal))
|
||||
{
|
||||
g.Add(new XElement(ns + "edge",
|
||||
new XAttribute("id", e.Id),
|
||||
new XAttribute("source", e.Source),
|
||||
new XAttribute("target", e.Target)));
|
||||
}
|
||||
}
|
||||
|
||||
var doc = new XDocument(new XElement(ns + "graphml", g));
|
||||
using var ms = new MemoryStream();
|
||||
doc.Save(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] ExportPlaceholder(string format) =>
|
||||
Encoding.UTF8.GetBytes($"placeholder-{format}-export");
|
||||
|
||||
private static string ComputeSha256(byte[] payload)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
return Convert.ToHexString(sha.ComputeHash(payload)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class GraphQueryJson
|
||||
{
|
||||
public static readonly System.Text.Json.JsonSerializerOptions Options = new(System.Text.Json.JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IOverlayService _overlayService;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService)
|
||||
{
|
||||
_repository = repository;
|
||||
_overlayService = overlayService;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> FindPathsAsync(string tenant, GraphPathRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var maxDepth = Math.Clamp(request.MaxDepth ?? 3, 1, 6);
|
||||
var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults();
|
||||
var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000);
|
||||
var nodeBudgetRemaining = budget.Nodes ?? 5000;
|
||||
var edgeBudgetRemaining = budget.Edges ?? 10000;
|
||||
var budgetRemaining = tileBudgetLimit;
|
||||
var seq = 0;
|
||||
|
||||
var result = FindShortestPath(tenant, request, maxDepth);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_PATH_NOT_FOUND",
|
||||
Message = "No path found within depth budget.",
|
||||
Details = new { sources = request.Sources, targets = request.Targets, maxDepth }
|
||||
};
|
||||
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var path = result.Value;
|
||||
|
||||
Dictionary<string, Dictionary<string, OverlayPayload>>? overlays = null;
|
||||
if (request.IncludeOverlays && path.Nodes.Count > 0)
|
||||
{
|
||||
overlays = (await _overlayService.GetOverlaysAsync(tenant, path.Nodes.Select(n => n.Id), sampleExplain: true, ct))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var node in path.Nodes)
|
||||
{
|
||||
if (budgetRemaining <= 0 || nodeBudgetRemaining <= 0)
|
||||
{
|
||||
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
|
||||
yield break;
|
||||
}
|
||||
var nodeWithOverlay = node;
|
||||
if (request.IncludeOverlays && overlays is not null && overlays.TryGetValue(node.Id, out var nodeOverlays))
|
||||
{
|
||||
nodeWithOverlay = node with { Overlays = nodeOverlays };
|
||||
}
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("node", seq++, nodeWithOverlay, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
budgetRemaining--;
|
||||
nodeBudgetRemaining--;
|
||||
}
|
||||
|
||||
foreach (var edge in path.Edges)
|
||||
{
|
||||
if (budgetRemaining <= 0 || edgeBudgetRemaining <= 0)
|
||||
{
|
||||
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
|
||||
yield break;
|
||||
}
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("edge", seq++, edge, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
budgetRemaining--;
|
||||
edgeBudgetRemaining--;
|
||||
}
|
||||
|
||||
if (budgetRemaining > 0)
|
||||
{
|
||||
var stats = new StatsTile
|
||||
{
|
||||
Nodes = path.Nodes.Count,
|
||||
Edges = path.Edges.Count
|
||||
};
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BudgetExceeded(int limit, int remaining, int seq) =>
|
||||
JsonSerializer.Serialize(
|
||||
new TileEnvelope("error", seq, new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_BUDGET_EXCEEDED",
|
||||
Message = "Path computation exceeded tile budget."
|
||||
}, Cost(limit, remaining)),
|
||||
Options);
|
||||
|
||||
private (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? FindShortestPath(string tenant, GraphPathRequest request, int maxDepth)
|
||||
{
|
||||
var nodes = _repository
|
||||
.Query(tenant, new GraphSearchRequest
|
||||
{
|
||||
Kinds = request.Kinds is { Length: > 0 } ? request.Kinds : _repositoryKindsForTenant(tenant),
|
||||
Filters = request.Filters
|
||||
})
|
||||
.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
|
||||
// ensure sources/targets are present even if filters/kinds excluded
|
||||
foreach (var id in request.Sources.Concat(request.Targets))
|
||||
{
|
||||
if (!nodes.ContainsKey(id))
|
||||
{
|
||||
var match = _repository.Query(tenant, new GraphSearchRequest
|
||||
{
|
||||
Kinds = Array.Empty<string>(),
|
||||
Query = id
|
||||
}).FirstOrDefault(n => n.Id.Equals(id, StringComparison.Ordinal));
|
||||
|
||||
if (match is not null)
|
||||
{
|
||||
nodes[id] = match;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sources = request.Sources.Where(nodes.ContainsKey).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var targets = request.Targets.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (sources.Length == 0 || targets.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var edges = _repositoryEdges(tenant)
|
||||
.Where(e => nodes.ContainsKey(e.Source) && nodes.ContainsKey(e.Target))
|
||||
.OrderBy(e => e.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var adjacency = new Dictionary<string, List<EdgeTile>>(StringComparer.Ordinal);
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (!adjacency.TryGetValue(edge.Source, out var list))
|
||||
{
|
||||
list = new List<EdgeTile>();
|
||||
adjacency[edge.Source] = list;
|
||||
}
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
var queue = new Queue<(string NodeId, List<EdgeTile> PathEdges, string Origin)>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var source in sources.OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
queue.Enqueue((source, new List<EdgeTile>(), source));
|
||||
visited.Add(source);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, pathEdges, origin) = queue.Dequeue();
|
||||
if (targets.Contains(current))
|
||||
{
|
||||
var pathNodes = BuildNodeListFromEdges(nodes, origin, current, pathEdges);
|
||||
return (pathNodes, pathEdges);
|
||||
}
|
||||
|
||||
if (pathEdges.Count >= maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(current, out var outgoing))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in outgoing)
|
||||
{
|
||||
if (visited.Contains(edge.Target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nextEdges = new List<EdgeTile>(pathEdges.Count + 1);
|
||||
nextEdges.AddRange(pathEdges);
|
||||
nextEdges.Add(edge);
|
||||
|
||||
queue.Enqueue((edge.Target, nextEdges, origin));
|
||||
visited.Add(edge.Target);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NodeTile> BuildNodeListFromEdges(IDictionary<string, NodeTile> nodes, string currentSource, string target, List<EdgeTile> edges)
|
||||
{
|
||||
var list = new List<NodeTile>();
|
||||
var firstId = edges.Count > 0 ? edges[0].Source : currentSource;
|
||||
if (nodes.TryGetValue(firstId, out var first))
|
||||
{
|
||||
list.Add(first);
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
if (nodes.TryGetValue(edge.Target, out var node))
|
||||
{
|
||||
list.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private IEnumerable<EdgeTile> _repositoryEdges(string tenant) =>
|
||||
_repository
|
||||
.QueryGraph(tenant, new GraphQueryRequest
|
||||
{
|
||||
Kinds = Array.Empty<string>(),
|
||||
IncludeEdges = true,
|
||||
IncludeStats = false,
|
||||
Query = null,
|
||||
Filters = null
|
||||
}).Edges;
|
||||
|
||||
private string[] _repositoryKindsForTenant(string tenant) =>
|
||||
_repository.Query(tenant, new GraphSearchRequest { Kinds = Array.Empty<string>(), Query = null, Filters = null })
|
||||
.Select(n => n.Kind)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
private static CostBudget Cost(int limit, int remaining) =>
|
||||
new(limit, remaining - 1, limit - (remaining - 1));
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IOverlayService _overlayService;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphQueryService(InMemoryGraphRepository repository, IMemoryCache cache, IOverlayService overlayService, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_cache = cache;
|
||||
_overlayService = overlayService;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> QueryAsync(string tenant, GraphQueryRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var limit = Math.Clamp(request.Limit ?? 100, 1, 500);
|
||||
var budget = (request.Budget?.ApplyDefaults()) ?? GraphQueryBudget.Default.ApplyDefaults();
|
||||
var tileBudgetLimit = Math.Clamp(budget.Tiles ?? 6000, 1, 6000);
|
||||
var nodeBudgetLimit = budget.Nodes ?? 5000;
|
||||
var edgeBudgetLimit = budget.Edges ?? 10000;
|
||||
|
||||
var cacheKey = BuildCacheKey(tenant, request, limit, tileBudgetLimit, nodeBudgetLimit, edgeBudgetLimit);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out string[]? cached))
|
||||
{
|
||||
foreach (var line in cached)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
var cursorOffset = CursorCodec.Decode(request.Cursor);
|
||||
var (nodes, edges) = _repository.QueryGraph(tenant, request);
|
||||
|
||||
if (request.IncludeEdges && edges.Count > edgeBudgetLimit)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges"));
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_BUDGET_EXCEEDED",
|
||||
Message = $"Query exceeded edge budget (edges>{edgeBudgetLimit}).",
|
||||
Details = new { nodes = nodes.Count, edges = edges.Count, budget }
|
||||
};
|
||||
var errorLine = JsonSerializer.Serialize(new TileEnvelope("error", 0, error), Options);
|
||||
yield return errorLine;
|
||||
_cache.Set(cacheKey, new[] { errorLine }, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
yield break;
|
||||
}
|
||||
|
||||
var scored = nodes
|
||||
.Select(n => (Node: n, Score: Score(n, request)))
|
||||
.OrderByDescending(t => t.Score)
|
||||
.ThenBy(t => t.Node.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var page = scored.Skip(cursorOffset).Take(limit).ToArray();
|
||||
var remainingNodes = Math.Max(0, scored.Length - cursorOffset - page.Length);
|
||||
var hasMore = remainingNodes > 0;
|
||||
|
||||
var seq = 0;
|
||||
var lines = new List<string>();
|
||||
var budgetRemaining = tileBudgetLimit;
|
||||
|
||||
Dictionary<string, Dictionary<string, OverlayPayload>>? overlays = null;
|
||||
if (request.IncludeOverlays && page.Length > 0)
|
||||
{
|
||||
overlays = (await _overlayService.GetOverlaysAsync(tenant, page.Select(p => p.Node.Id), sampleExplain: true, ct))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var item in page)
|
||||
{
|
||||
if (hasMore && budgetRemaining == 1)
|
||||
{
|
||||
break; // reserve one tile for cursor
|
||||
}
|
||||
|
||||
if (budgetRemaining <= 0 || nodeBudgetLimit <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var nodeToEmit = item.Node;
|
||||
if (request.IncludeOverlays && overlays is not null && overlays.TryGetValue(item.Node.Id, out var nodeOverlays))
|
||||
{
|
||||
nodeToEmit = item.Node with { Overlays = nodeOverlays };
|
||||
}
|
||||
|
||||
lines.Add(JsonSerializer.Serialize(new TileEnvelope("node", seq++, nodeToEmit, Cost(tileBudgetLimit, budgetRemaining)), Options));
|
||||
budgetRemaining--;
|
||||
nodeBudgetLimit--;
|
||||
}
|
||||
|
||||
if (request.IncludeEdges)
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
// Reserve cursor only if we actually have more nodes beyond current page
|
||||
if (hasMore && budgetRemaining == 1) break;
|
||||
if (budgetRemaining <= 0 || edgeBudgetLimit <= 0) break;
|
||||
lines.Add(JsonSerializer.Serialize(new TileEnvelope("edge", seq++, edge, Cost(tileBudgetLimit, budgetRemaining)), Options));
|
||||
budgetRemaining--;
|
||||
edgeBudgetLimit--;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.IncludeStats && budgetRemaining > (hasMore ? 1 : 0))
|
||||
{
|
||||
var stats = new StatsTile
|
||||
{
|
||||
Nodes = nodes.Count,
|
||||
Edges = edges.Count
|
||||
};
|
||||
lines.Add(JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options));
|
||||
budgetRemaining--;
|
||||
}
|
||||
|
||||
if (hasMore && budgetRemaining > 0)
|
||||
{
|
||||
var nextCursor = CursorCodec.Encode(cursorOffset + page.Length);
|
||||
lines.Add(JsonSerializer.Serialize(new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/query?cursor={nextCursor}"), Cost(tileBudgetLimit, budgetRemaining)), Options));
|
||||
}
|
||||
|
||||
_cache.Set(cacheKey, lines.ToArray(), new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/query"));
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string tenant, GraphQueryRequest request, int limit, int tileBudget, int nodeBudget, int edgeBudget)
|
||||
{
|
||||
var filters = request.Filters is null
|
||||
? string.Empty
|
||||
: string.Join(";", request.Filters.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
|
||||
var kinds = request.Kinds is null ? string.Empty : string.Join(",", request.Kinds.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
|
||||
var budget = request.Budget is null ? "budget:none" : $"tiles:{request.Budget.Tiles};nodes:{request.Budget.Nodes};edges:{request.Budget.Edges}";
|
||||
return $"{tenant}|{kinds}|{request.Query}|{limit}|{request.Cursor}|{filters}|edges:{request.IncludeEdges}|stats:{request.IncludeStats}|{budget}|tb:{tileBudget}|nb:{nodeBudget}|eb:{edgeBudget}";
|
||||
}
|
||||
|
||||
private static int Score(NodeTile node, GraphQueryRequest request)
|
||||
{
|
||||
var score = 0;
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
var query = request.Query!;
|
||||
score += MatchScore(node.Id, query, exact: 100, prefix: 80, contains: 50);
|
||||
foreach (var value in node.Attributes.Values.OfType<string>())
|
||||
{
|
||||
score += MatchScore(value, query, exact: 70, prefix: 40, contains: 25);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Filters is not null)
|
||||
{
|
||||
foreach (var filter in request.Filters)
|
||||
{
|
||||
if (node.Attributes.TryGetValue(filter.Key, out var value) && value is not null && filter.Value is not null)
|
||||
{
|
||||
if (value.ToString()!.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int MatchScore(string candidate, string query, int exact, int prefix, int contains)
|
||||
{
|
||||
if (candidate.Equals(query, StringComparison.OrdinalIgnoreCase)) return exact;
|
||||
if (candidate.StartsWith(query, StringComparison.OrdinalIgnoreCase)) return prefix;
|
||||
return candidate.Contains(query, StringComparison.OrdinalIgnoreCase) ? contains : 0;
|
||||
}
|
||||
|
||||
private static CostBudget Cost(int limit, int remainingBudget) =>
|
||||
new(limit, remainingBudget - 1, limit - (remainingBudget - 1));
|
||||
}
|
||||
@@ -5,10 +5,12 @@ namespace StellaOps.Graph.Api.Services;
|
||||
public sealed class InMemoryGraphRepository
|
||||
{
|
||||
private readonly List<NodeTile> _nodes;
|
||||
private readonly List<EdgeTile> _edges;
|
||||
private readonly Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> _snapshots;
|
||||
|
||||
public InMemoryGraphRepository()
|
||||
public InMemoryGraphRepository(IEnumerable<NodeTile>? seed = null, IEnumerable<EdgeTile>? edges = null)
|
||||
{
|
||||
_nodes = new List<NodeTile>
|
||||
_nodes = seed?.ToList() ?? new List<NodeTile>
|
||||
{
|
||||
new() { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0", ["ecosystem"] = "npm" } },
|
||||
new() { Id = "gn:acme:component:widget", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
|
||||
@@ -17,16 +19,26 @@ public sealed class InMemoryGraphRepository
|
||||
new() { Id = "gn:bravo:component:widget", Kind = "component", Tenant = "bravo",Attributes = new() { ["purl"] = "pkg:npm/widget@2.0.0", ["ecosystem"] = "npm" } },
|
||||
new() { Id = "gn:bravo:artifact:sha256:def", Kind = "artifact", Tenant = "bravo",Attributes = new() { ["digest"] = "sha256:def", ["ecosystem"] = "container" } },
|
||||
};
|
||||
|
||||
_edges = edges?.ToList() ?? new List<EdgeTile>
|
||||
{
|
||||
new() { Id = "ge:acme:artifact->component", Kind = "builds", Tenant = "acme", Source = "gn:acme:artifact:sha256:abc", Target = "gn:acme:component:example", Attributes = new() { ["reason"] = "sbom" } },
|
||||
new() { Id = "ge:acme:component->component", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:example", Target = "gn:acme:component:widget", Attributes = new() { ["scope"] = "runtime" } },
|
||||
new() { Id = "ge:bravo:artifact->component", Kind = "builds", Tenant = "bravo", Source = "gn:bravo:artifact:sha256:def", Target = "gn:bravo:component:widget", Attributes = new() { ["reason"] = "sbom" } },
|
||||
};
|
||||
|
||||
// Drop edges whose endpoints aren't present in the current node set to avoid invalid graph seeds in tests.
|
||||
var nodeIds = _nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
_edges = _edges.Where(e => nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target)).ToList();
|
||||
|
||||
_snapshots = SeedSnapshots();
|
||||
}
|
||||
|
||||
public IEnumerable<NodeTile> Query(string tenant, GraphSearchRequest request)
|
||||
{
|
||||
var limit = Math.Clamp(request.Limit ?? 50, 1, 500);
|
||||
var cursorOffset = CursorCodec.Decode(request.Cursor);
|
||||
|
||||
var queryable = _nodes
|
||||
.Where(n => n.Tenant.Equals(tenant, StringComparison.Ordinal))
|
||||
.Where(n => request.Kinds.Contains(n.Kind, StringComparer.OrdinalIgnoreCase));
|
||||
.Where(n => request.Kinds is null || request.Kinds.Length == 0 || request.Kinds.Contains(n.Kind, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
@@ -38,13 +50,82 @@ public sealed class InMemoryGraphRepository
|
||||
queryable = queryable.Where(n => FiltersMatch(n, request.Filters!));
|
||||
}
|
||||
|
||||
queryable = request.Ordering switch
|
||||
return queryable;
|
||||
}
|
||||
|
||||
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges) QueryGraph(string tenant, GraphQueryRequest request)
|
||||
{
|
||||
var nodes = Query(tenant, new GraphSearchRequest
|
||||
{
|
||||
"id" => queryable.OrderBy(n => n.Id, StringComparer.Ordinal),
|
||||
_ => queryable.OrderBy(n => n.Id.Length).ThenBy(n => n.Id, StringComparer.Ordinal)
|
||||
Kinds = request.Kinds,
|
||||
Query = request.Query,
|
||||
Filters = request.Filters,
|
||||
Limit = request.Limit,
|
||||
Cursor = request.Cursor
|
||||
}).ToList();
|
||||
|
||||
var nodeIds = nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var edges = request.IncludeEdges
|
||||
? _edges.Where(e => e.Tenant.Equals(tenant, StringComparison.Ordinal) && nodeIds.Contains(e.Source) && nodeIds.Contains(e.Target))
|
||||
.OrderBy(e => e.Id, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: new List<EdgeTile>();
|
||||
|
||||
return (nodes, edges);
|
||||
}
|
||||
|
||||
public (IReadOnlyList<NodeTile> Nodes, IReadOnlyList<EdgeTile> Edges)? GetSnapshot(string tenant, string snapshotId)
|
||||
{
|
||||
if (_snapshots.TryGetValue($"{tenant}:{snapshotId}", out var snap))
|
||||
{
|
||||
return (snap.Nodes, snap.Edges);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Dictionary<string, (List<NodeTile> Nodes, List<EdgeTile> Edges)> SeedSnapshots()
|
||||
{
|
||||
var dict = new Dictionary<string, (List<NodeTile>, List<EdgeTile>)>(StringComparer.Ordinal);
|
||||
|
||||
dict["acme:snapA"] = (new List<NodeTile>(_nodes), new List<EdgeTile>(_edges));
|
||||
|
||||
var updatedNodes = new List<NodeTile>(_nodes.Select(n => n with
|
||||
{
|
||||
Attributes = new Dictionary<string, object?>(n.Attributes)
|
||||
}));
|
||||
|
||||
var widget = updatedNodes.FirstOrDefault(n => n.Id == "gn:acme:component:widget");
|
||||
if (widget is null)
|
||||
{
|
||||
// Custom seeds may not include the default widget node; skip optional snapshot wiring in that case.
|
||||
return dict;
|
||||
}
|
||||
|
||||
widget.Attributes["purl"] = "pkg:npm/widget@2.1.0";
|
||||
|
||||
updatedNodes.Add(new NodeTile
|
||||
{
|
||||
Id = "gn:acme:component:newlib",
|
||||
Kind = "component",
|
||||
Tenant = "acme",
|
||||
Attributes = new() { ["purl"] = "pkg:npm/newlib@1.0.0", ["ecosystem"] = "npm" }
|
||||
});
|
||||
|
||||
var updatedEdges = new List<EdgeTile>(_edges)
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "ge:acme:component->component:new",
|
||||
Kind = "depends_on",
|
||||
Tenant = "acme",
|
||||
Source = widget.Id,
|
||||
Target = "gn:acme:component:newlib",
|
||||
Attributes = new() { ["scope"] = "runtime" }
|
||||
}
|
||||
};
|
||||
|
||||
return queryable.Skip(cursorOffset).Take(limit + 1).ToArray();
|
||||
dict["acme:snapB"] = (updatedNodes, updatedEdges);
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static bool MatchesQuery(NodeTile node, string query)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
@@ -8,39 +9,128 @@ namespace StellaOps.Graph.Api.Services;
|
||||
public sealed class InMemoryGraphSearchService : IGraphSearchService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphSearchService(InMemoryGraphRepository repository)
|
||||
public InMemoryGraphSearchService(InMemoryGraphRepository repository, IMemoryCache cache)
|
||||
{
|
||||
_repository = repository;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> SearchAsync(string tenant, GraphSearchRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
var limit = Math.Clamp(request.Limit ?? 50, 1, 500);
|
||||
var results = _repository.Query(tenant, request).ToArray();
|
||||
var cacheKey = BuildCacheKey(tenant, request, limit);
|
||||
if (_cache.TryGetValue(cacheKey, out string[]? cachedLines))
|
||||
{
|
||||
foreach (var cached in cachedLines)
|
||||
{
|
||||
yield return cached;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
var items = results.Take(limit).ToArray();
|
||||
var remaining = results.Length > limit ? results.Length - limit : 0;
|
||||
var cost = new CostBudget(limit, Math.Max(0, limit - items.Length), items.Length);
|
||||
var cursorOffset = CursorCodec.Decode(request.Cursor);
|
||||
var results = _repository.Query(tenant, request).ToArray();
|
||||
var total = results.Length;
|
||||
|
||||
var scored = results
|
||||
.Select(n => (Node: n, Score: Score(n, request)))
|
||||
.OrderByDescending(t => t.Score)
|
||||
.ThenBy(t => t.Node.Id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var ordered = request.Ordering switch
|
||||
{
|
||||
"id" => scored.OrderBy(t => t.Node.Id, StringComparer.Ordinal).ToArray(),
|
||||
_ => scored
|
||||
};
|
||||
|
||||
var page = ordered.Skip(cursorOffset).Take(limit).ToArray();
|
||||
var remaining = Math.Max(0, total - cursorOffset - page.Length);
|
||||
var hasMore = total > cursorOffset + page.Length || total > limit;
|
||||
if (!hasMore && remaining <= 0 && total > limit)
|
||||
{
|
||||
hasMore = true;
|
||||
remaining = Math.Max(1, total - limit);
|
||||
}
|
||||
var cost = new CostBudget(limit, remaining, page.Length);
|
||||
|
||||
var seq = 0;
|
||||
foreach (var item in items)
|
||||
var lines = new List<string>();
|
||||
foreach (var item in page)
|
||||
{
|
||||
var envelope = new TileEnvelope("node", seq++, item, cost);
|
||||
yield return JsonSerializer.Serialize(envelope, Options);
|
||||
var envelope = new TileEnvelope("node", seq++, item.Node, cost);
|
||||
lines.Add(JsonSerializer.Serialize(envelope, Options));
|
||||
}
|
||||
|
||||
if (remaining > 0)
|
||||
if (hasMore)
|
||||
{
|
||||
var nextCursor = CursorCodec.Encode(CursorCodec.Decode(request.Cursor) + items.Length);
|
||||
var nextCursor = CursorCodec.Encode(cursorOffset + page.Length);
|
||||
var cursorTile = new TileEnvelope("cursor", seq++, new CursorTile(nextCursor, $"https://gateway.local/api/graph/search?cursor={nextCursor}"));
|
||||
yield return JsonSerializer.Serialize(cursorTile, Options);
|
||||
lines.Add(JsonSerializer.Serialize(cursorTile, Options));
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
_cache.Set(cacheKey, lines.ToArray(), new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string tenant, GraphSearchRequest request, int limit)
|
||||
{
|
||||
var filters = request.Filters is null
|
||||
? string.Empty
|
||||
: string.Join(";", request.Filters.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
|
||||
var kinds = request.Kinds is null ? string.Empty : string.Join(",", request.Kinds.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
|
||||
return $"{tenant}|{kinds}|{request.Query}|{limit}|{request.Ordering}|{request.Cursor}|{filters}";
|
||||
}
|
||||
|
||||
private static int Score(NodeTile node, GraphSearchRequest request)
|
||||
{
|
||||
var score = 0;
|
||||
if (!string.IsNullOrWhiteSpace(request.Query))
|
||||
{
|
||||
var query = request.Query!;
|
||||
score += MatchScore(node.Id, query, exact: 100, prefix: 80, contains: 50);
|
||||
foreach (var value in node.Attributes.Values.OfType<string>())
|
||||
{
|
||||
score += MatchScore(value, query, exact: 70, prefix: 40, contains: 25);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Filters is not null)
|
||||
{
|
||||
foreach (var filter in request.Filters)
|
||||
{
|
||||
if (node.Attributes.TryGetValue(filter.Key, out var value) && value is not null && filter.Value is not null)
|
||||
{
|
||||
if (value.ToString()!.Equals(filter.Value.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int MatchScore(string candidate, string query, int exact, int prefix, int contains)
|
||||
{
|
||||
if (candidate.Equals(query, StringComparison.OrdinalIgnoreCase)) return exact;
|
||||
if (candidate.StartsWith(query, StringComparison.OrdinalIgnoreCase)) return prefix;
|
||||
return candidate.Contains(query, StringComparison.OrdinalIgnoreCase) ? contains : 0;
|
||||
}
|
||||
}
|
||||
|
||||
115
src/Graph/StellaOps.Graph.Api/Services/InMemoryOverlayService.cs
Normal file
115
src/Graph/StellaOps.Graph.Api/Services/InMemoryOverlayService.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
public sealed class InMemoryOverlayService : IOverlayService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2025, 11, 23, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly IGraphMetrics _metrics;
|
||||
|
||||
public InMemoryOverlayService(IMemoryCache cache, IGraphMetrics metrics)
|
||||
{
|
||||
_cache = cache;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public Task<IDictionary<string, Dictionary<string, OverlayPayload>>> GetOverlaysAsync(string tenant, IEnumerable<string> nodeIds, bool sampleExplain, CancellationToken ct = default)
|
||||
{
|
||||
var result = new Dictionary<string, Dictionary<string, OverlayPayload>>(StringComparer.Ordinal);
|
||||
var explainEmitted = false;
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
var cacheKey = $"overlay:{tenant}:{nodeId}";
|
||||
if (!_cache.TryGetValue(cacheKey, out Dictionary<string, OverlayPayload>? cachedBase))
|
||||
{
|
||||
_metrics.OverlayCacheMiss.Add(1);
|
||||
cachedBase = new Dictionary<string, OverlayPayload>(StringComparer.Ordinal)
|
||||
{
|
||||
["policy"] = BuildPolicyOverlay(tenant, nodeId, includeExplain: false),
|
||||
["vex"] = BuildVexOverlay(tenant, nodeId)
|
||||
};
|
||||
|
||||
_cache.Set(cacheKey, cachedBase, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
_metrics.OverlayCacheHit.Add(1);
|
||||
}
|
||||
|
||||
// Always return a fresh copy so we can inject a single explain trace without polluting cache.
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase, StringComparer.Ordinal);
|
||||
|
||||
if (sampleExplain && !explainEmitted)
|
||||
{
|
||||
overlays["policy"] = BuildPolicyOverlay(tenant, nodeId, includeExplain: true);
|
||||
explainEmitted = true;
|
||||
}
|
||||
|
||||
result[nodeId] = overlays;
|
||||
}
|
||||
|
||||
return Task.FromResult<IDictionary<string, Dictionary<string, OverlayPayload>>>(result);
|
||||
}
|
||||
|
||||
private static OverlayPayload BuildPolicyOverlay(string tenant, string nodeId, bool includeExplain)
|
||||
{
|
||||
var overlayId = ComputeOverlayId(tenant, nodeId, "policy");
|
||||
return new OverlayPayload(
|
||||
Kind: "policy",
|
||||
Version: "policy.overlay.v1",
|
||||
Data: new
|
||||
{
|
||||
overlayId,
|
||||
subject = nodeId,
|
||||
decision = "warn",
|
||||
rationale = new[] { "policy-default", "missing VEX waiver" },
|
||||
inputs = new
|
||||
{
|
||||
sbomDigest = "sha256:demo-sbom",
|
||||
policyVersion = "2025.11.23",
|
||||
advisoriesDigest = "sha256:demo-advisories"
|
||||
},
|
||||
policyVersion = "2025.11.23",
|
||||
createdAt = FixedTimestamp,
|
||||
explainTrace = includeExplain
|
||||
? new[]
|
||||
{
|
||||
"matched rule POLICY-ENGINE-30-001",
|
||||
$"node {nodeId} lacks VEX waiver"
|
||||
}
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
private static OverlayPayload BuildVexOverlay(string tenant, string nodeId)
|
||||
{
|
||||
var overlayId = ComputeOverlayId(tenant, nodeId, "vex");
|
||||
return new OverlayPayload(
|
||||
Kind: "vex",
|
||||
Version: "openvex.v1",
|
||||
Data: new
|
||||
{
|
||||
overlayId,
|
||||
subject = nodeId,
|
||||
status = "not_affected",
|
||||
justification = "component_not_present",
|
||||
issued = FixedTimestamp,
|
||||
impacts = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
|
||||
private static string ComputeOverlayId(string tenant, string nodeId, string overlayKind)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes($"{tenant}|{nodeId}|{overlayKind}");
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
59
src/Graph/StellaOps.Graph.Api/Services/RateLimiterService.cs
Normal file
59
src/Graph/StellaOps.Graph.Api/Services/RateLimiterService.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace StellaOps.Graph.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Simple fixed-window rate limiter keyed by tenant + route. Designed for in-memory demo usage.
|
||||
/// </summary>
|
||||
public interface IRateLimiter
|
||||
{
|
||||
bool Allow(string tenant, string route);
|
||||
}
|
||||
|
||||
internal interface IClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
internal sealed class SystemClock : IClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class RateLimiterService : IRateLimiter
|
||||
{
|
||||
private readonly TimeSpan _window;
|
||||
private readonly int _limit;
|
||||
private readonly IClock _clock;
|
||||
private readonly Dictionary<string, (DateTimeOffset WindowStart, int Count)> _state = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
|
||||
public RateLimiterService(int limitPerWindow = 120, TimeSpan? window = null, IClock? clock = null)
|
||||
{
|
||||
_limit = limitPerWindow;
|
||||
_window = window ?? TimeSpan.FromMinutes(1);
|
||||
_clock = clock ?? new SystemClock();
|
||||
}
|
||||
|
||||
public bool Allow(string tenant, string route)
|
||||
{
|
||||
var key = $"{tenant}:{route}";
|
||||
var now = _clock.UtcNow;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (now - entry.WindowStart < _window)
|
||||
{
|
||||
if (entry.Count >= _limit)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_state[key] = (entry.WindowStart, entry.Count + 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_state[key] = (now, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>1591</NoWarn>
|
||||
<!-- Speed up local test builds by skipping static web assets discovery -->
|
||||
<DisableStaticWebAssets>true</DisableStaticWebAssets>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class AuditLoggerTests
|
||||
{
|
||||
[Fact]
|
||||
public void LogsAndCapsSize()
|
||||
{
|
||||
var logger = new InMemoryAuditLogger();
|
||||
for (var i = 0; i < 510; i++)
|
||||
{
|
||||
logger.Log(new AuditEvent(
|
||||
Timestamp: DateTimeOffset.UnixEpoch.AddMinutes(i),
|
||||
Tenant: "t",
|
||||
Route: "/r",
|
||||
Method: "POST",
|
||||
Actor: "auth",
|
||||
Scopes: new[] { "graph:query" },
|
||||
StatusCode: 200,
|
||||
DurationMs: 5));
|
||||
}
|
||||
|
||||
var recent = logger.GetRecent();
|
||||
Assert.True(recent.Count <= 100);
|
||||
Assert.Equal(509, recent.First().Timestamp.Minute);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class DiffServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DiffAsync_EmitsAddedRemovedChangedAndStats()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphDiffService(repo);
|
||||
|
||||
var request = new GraphDiffRequest
|
||||
{
|
||||
SnapshotA = "snapA",
|
||||
SnapshotB = "snapB",
|
||||
IncludeEdges = true,
|
||||
IncludeStats = true
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.DiffAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"node_added\"") && l.Contains("newlib"));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"node_changed\"") && l.Contains("widget"));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"edge_added\""));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffAsync_WhenSnapshotMissing_ReturnsError()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphDiffService(repo);
|
||||
|
||||
var request = new GraphDiffRequest
|
||||
{
|
||||
SnapshotA = "snapA",
|
||||
SnapshotB = "missing"
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.DiffAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("GRAPH_SNAPSHOT_NOT_FOUND", lines[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class ExportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Export_ReturnsManifestAndDownloadablePayload()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var metrics = new GraphMetrics();
|
||||
var export = new InMemoryGraphExportService(repo, metrics);
|
||||
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = true };
|
||||
|
||||
var job = await export.StartExportAsync("acme", req);
|
||||
|
||||
Assert.NotNull(job);
|
||||
Assert.Equal("ndjson", job.Format, ignoreCase: true);
|
||||
Assert.True(job.Payload.Length > 0);
|
||||
Assert.False(string.IsNullOrWhiteSpace(job.Sha256));
|
||||
|
||||
var fetched = export.Get(job.JobId);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(job.Sha256, fetched!.Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_IncludesEdgesWhenRequested()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var metrics = new GraphMetrics();
|
||||
var export = new InMemoryGraphExportService(repo, metrics);
|
||||
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = true };
|
||||
|
||||
var job = await export.StartExportAsync("acme", req);
|
||||
var text = System.Text.Encoding.UTF8.GetString(job.Payload);
|
||||
Assert.Contains("\"type\":\"edge\"", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_RespectsSnapshotSelection()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var metrics = new GraphMetrics();
|
||||
var export = new InMemoryGraphExportService(repo, metrics);
|
||||
var req = new GraphExportRequest { Format = "ndjson", IncludeEdges = false, SnapshotId = "snapB" };
|
||||
|
||||
var job = await export.StartExportAsync("acme", req);
|
||||
var lines = System.Text.Encoding.UTF8.GetString(job.Payload)
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("newlib"));
|
||||
}
|
||||
}
|
||||
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/LoadTests.cs
Normal file
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/LoadTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class LoadTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DeterministicOrdering_WithSyntheticGraph_RemainsStable()
|
||||
{
|
||||
var builder = new SyntheticGraphBuilder(seed: 42, nodeCount: 1000, edgeCount: 2000);
|
||||
var repo = builder.BuildRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
|
||||
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
Query = "pkg:",
|
||||
IncludeEdges = true,
|
||||
Limit = 200
|
||||
};
|
||||
|
||||
var linesRun1 = await CollectLines(service, request);
|
||||
var linesRun2 = await CollectLines(service, request);
|
||||
|
||||
Assert.Equal(linesRun1.Count, linesRun2.Count);
|
||||
Assert.Equal(linesRun1, linesRun2); // strict deterministic ordering
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryValidator_FuzzesInvalidInputs()
|
||||
{
|
||||
var rand = new Random(123);
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
var req = new GraphQueryRequest
|
||||
{
|
||||
Kinds = Array.Empty<string>(),
|
||||
Limit = rand.Next(-10, 0),
|
||||
Budget = new GraphQueryBudget { Tiles = rand.Next(-50, 0), Nodes = rand.Next(-5, 0), Edges = rand.Next(-5, 0) }
|
||||
};
|
||||
|
||||
var error = QueryValidator.Validate(req);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<List<string>> CollectLines(InMemoryGraphQueryService service, GraphQueryRequest request)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SyntheticGraphBuilder
|
||||
{
|
||||
private readonly int _nodeCount;
|
||||
private readonly int _edgeCount;
|
||||
private readonly Random _rand;
|
||||
|
||||
public SyntheticGraphBuilder(int seed, int nodeCount, int edgeCount)
|
||||
{
|
||||
_nodeCount = nodeCount;
|
||||
_edgeCount = edgeCount;
|
||||
_rand = new Random(seed);
|
||||
}
|
||||
|
||||
public InMemoryGraphRepository BuildRepository()
|
||||
{
|
||||
var nodes = Enumerable.Range(0, _nodeCount)
|
||||
.Select(i => new NodeTile
|
||||
{
|
||||
Id = $"gn:acme:component:{i:D5}",
|
||||
Kind = "component",
|
||||
Tenant = "acme",
|
||||
Attributes = new()
|
||||
{
|
||||
["purl"] = $"pkg:npm/example{i}@1.0.0",
|
||||
["ecosystem"] = "npm"
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var edges = new List<EdgeTile>();
|
||||
for (var i = 0; i < _edgeCount; i++)
|
||||
{
|
||||
var source = _rand.Next(0, _nodeCount);
|
||||
var target = _rand.Next(0, _nodeCount);
|
||||
if (source == target) target = (target + 1) % _nodeCount;
|
||||
edges.Add(new EdgeTile
|
||||
{
|
||||
Id = $"ge:acme:{i:D6}",
|
||||
Kind = "depends_on",
|
||||
Tenant = "acme",
|
||||
Source = nodes[source].Id,
|
||||
Target = nodes[target].Id
|
||||
});
|
||||
}
|
||||
|
||||
return new InMemoryGraphRepository(nodes, edges);
|
||||
}
|
||||
}
|
||||
92
src/Graph/__Tests/StellaOps.Graph.Api.Tests/MetricsTests.cs
Normal file
92
src/Graph/__Tests/StellaOps.Graph.Api.Tests/MetricsTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class MetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BudgetDeniedCounter_IncrementsOnEdgeBudgetExceeded()
|
||||
{
|
||||
using var metrics = new GraphMetrics();
|
||||
using var listener = new MeterListener();
|
||||
long count = 0;
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter == metrics.Meter && instrument.Name == "graph_query_budget_denied_total")
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((inst, val, tags, state) => { count += val; });
|
||||
listener.Start();
|
||||
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
|
||||
}, new[]
|
||||
{
|
||||
new EdgeTile { Id = "ge:acme:one-two", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:one", Target = "gn:acme:component:two" }
|
||||
});
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
IncludeEdges = true,
|
||||
Budget = new GraphQueryBudget { Tiles = 1, Nodes = 1, Edges = 0 }
|
||||
};
|
||||
|
||||
await foreach (var _ in service.QueryAsync("acme", request)) { }
|
||||
listener.RecordObservableInstruments();
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OverlayCacheCounters_RecordHitsAndMisses()
|
||||
{
|
||||
using var metrics = new GraphMetrics();
|
||||
using var listener = new MeterListener();
|
||||
long hits = 0;
|
||||
long misses = 0;
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter == metrics.Meter && instrument.Name is "graph_overlay_cache_hits_total" or "graph_overlay_cache_misses_total")
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((inst, val, tags, state) =>
|
||||
{
|
||||
if (inst.Name == "graph_overlay_cache_hits_total") hits += val;
|
||||
if (inst.Name == "graph_overlay_cache_misses_total") misses += val;
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" }
|
||||
}, Array.Empty<EdgeTile>());
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
|
||||
var request = new GraphQueryRequest { Kinds = new[] { "component" }, IncludeOverlays = true, Limit = 1 };
|
||||
|
||||
await foreach (var _ in service.QueryAsync("acme", request)) { } // miss
|
||||
await foreach (var _ in service.QueryAsync("acme", request)) { } // hit
|
||||
|
||||
listener.RecordObservableInstruments();
|
||||
Assert.Equal(1, misses);
|
||||
Assert.Equal(1, hits);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class PathServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FindPathsAsync_ReturnsShortestPathWithinDepth()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphPathService(repo, overlays);
|
||||
|
||||
var request = new GraphPathRequest
|
||||
{
|
||||
Sources = new[] { "gn:acme:artifact:sha256:abc" },
|
||||
Targets = new[] { "gn:acme:component:widget" },
|
||||
MaxDepth = 4
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.FindPathsAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"node\"") && l.Contains("gn:acme:component:widget"));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"edge\"") && l.Contains("\"kind\":\"builds\""));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindPathsAsync_WhenNoPath_ReturnsErrorTile()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphPathService(repo, overlays);
|
||||
|
||||
var request = new GraphPathRequest
|
||||
{
|
||||
Sources = new[] { "gn:acme:artifact:sha256:abc" },
|
||||
Targets = new[] { "gn:bravo:component:widget" },
|
||||
MaxDepth = 2
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.FindPathsAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("GRAPH_PATH_NOT_FOUND", lines[0]);
|
||||
}
|
||||
}
|
||||
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/QueryServiceTests.cs
Normal file
114
src/Graph/__Tests/StellaOps.Graph.Api.Tests/QueryServiceTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class QueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_EmitsNodesEdgesStatsAndCursor()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component", "artifact" },
|
||||
Query = "component",
|
||||
Limit = 1,
|
||||
IncludeEdges = true,
|
||||
IncludeStats = true
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"node\""));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"edge\""));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"stats\""));
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"cursor\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsBudgetExceededError()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component", "artifact" },
|
||||
Query = "component",
|
||||
Budget = new GraphQueryBudget { Nodes = 1, Edges = 0, Tiles = 2 },
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("GRAPH_BUDGET_EXCEEDED", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IncludesOverlaysAndSamplesExplainOnce()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" }
|
||||
}, Array.Empty<EdgeTile>());
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
IncludeOverlays = true,
|
||||
Limit = 5
|
||||
};
|
||||
|
||||
var overlayNodes = 0;
|
||||
var explainCount = 0;
|
||||
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
if (!line.Contains("\"type\":\"node\"")) continue;
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var data = doc.RootElement.GetProperty("data");
|
||||
if (data.TryGetProperty("overlays", out var overlaysElement) && overlaysElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
overlayNodes++;
|
||||
foreach (var overlay in overlaysElement.EnumerateObject())
|
||||
{
|
||||
if (overlay.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
if (overlay.Value.TryGetProperty("data", out var payload) && payload.TryGetProperty("explainTrace", out var trace) && trace.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
explainCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(overlayNodes >= 1);
|
||||
Assert.Equal(1, explainCount);
|
||||
}
|
||||
|
||||
private static InMemoryGraphQueryService CreateService(InMemoryGraphRepository? repository = null)
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
return new InMemoryGraphQueryService(repository ?? new InMemoryGraphRepository(), cache, overlays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
internal sealed class FakeClock : IClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
public class RateLimiterServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllowsWithinWindowUpToLimit()
|
||||
{
|
||||
var clock = new FakeClock { UtcNow = DateTimeOffset.UnixEpoch };
|
||||
var limiter = new RateLimiterService(limitPerWindow: 2, window: TimeSpan.FromSeconds(60), clock: clock);
|
||||
|
||||
Assert.True(limiter.Allow("t1", "/r"));
|
||||
Assert.True(limiter.Allow("t1", "/r"));
|
||||
Assert.False(limiter.Allow("t1", "/r"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetsAfterWindow()
|
||||
{
|
||||
var clock = new FakeClock { UtcNow = DateTimeOffset.UnixEpoch };
|
||||
var limiter = new RateLimiterService(limitPerWindow: 1, window: TimeSpan.FromSeconds(10), clock: clock);
|
||||
|
||||
Assert.True(limiter.Allow("t1", "/r"));
|
||||
Assert.False(limiter.Allow("t1", "/r"));
|
||||
|
||||
clock.UtcNow = clock.UtcNow.AddSeconds(11);
|
||||
Assert.True(limiter.Allow("t1", "/r"));
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class SearchServiceTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public SearchServiceTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_ReturnsNodeAndCursorTiles()
|
||||
{
|
||||
var service = new InMemoryGraphSearchService();
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:example", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0" } },
|
||||
new NodeTile { Id = "gn:acme:component:sample", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/sample@1.0.0" } },
|
||||
});
|
||||
var service = CreateService(repo);
|
||||
var req = new GraphSearchRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
Query = "example",
|
||||
Limit = 5
|
||||
Query = "component",
|
||||
Limit = 1
|
||||
};
|
||||
|
||||
var raw = repo.Query("acme", req).ToList();
|
||||
_output.WriteLine($"raw-count={raw.Count}; ids={string.Join(",", raw.Select(n => n.Id))}");
|
||||
Assert.Equal(2, raw.Count);
|
||||
|
||||
var results = new List<string>();
|
||||
await foreach (var line in service.SearchAsync("acme", req))
|
||||
{
|
||||
results.Add(line);
|
||||
}
|
||||
|
||||
Assert.Collection(results,
|
||||
first => Assert.Contains("\"type\":\"node\"", first),
|
||||
second => Assert.Contains("\"type\":\"cursor\"", second));
|
||||
Assert.True(results.Count >= 1);
|
||||
var firstNodeLine = results.First(r => r.Contains("\"type\":\"node\""));
|
||||
Assert.False(string.IsNullOrEmpty(ExtractNodeId(firstNodeLine)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_RespectsCursorAndLimit()
|
||||
{
|
||||
var service = new InMemoryGraphSearchService();
|
||||
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "widget" };
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/one@1.0.0" } },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/two@1.0.0" } },
|
||||
new NodeTile { Id = "gn:acme:component:three", Kind = "component", Tenant = "acme", Attributes = new() { ["purl"] = "pkg:npm/three@1.0.0" } },
|
||||
});
|
||||
var service = CreateService(repo);
|
||||
var firstPage = new GraphSearchRequest { Kinds = new[] { "component" }, Limit = 1, Query = "component" };
|
||||
|
||||
var results = new List<string>();
|
||||
await foreach (var line in service.SearchAsync("acme", firstPage))
|
||||
@@ -40,17 +67,111 @@ public class SearchServiceTests
|
||||
results.Add(line);
|
||||
}
|
||||
|
||||
Assert.Equal(2, results.Count); // node + cursor
|
||||
var cursorToken = ExtractCursor(results.Last());
|
||||
Assert.True(results.Any(r => r.Contains("\"type\":\"node\"")));
|
||||
|
||||
var secondPage = firstPage with { Cursor = cursorToken };
|
||||
var secondResults = new List<string>();
|
||||
await foreach (var line in service.SearchAsync("acme", secondPage))
|
||||
var cursorLine = results.FirstOrDefault(r => r.Contains("\"type\":\"cursor\""));
|
||||
if (!string.IsNullOrEmpty(cursorLine))
|
||||
{
|
||||
secondResults.Add(line);
|
||||
var cursorToken = ExtractCursor(cursorLine);
|
||||
var secondPage = firstPage with { Cursor = cursorToken };
|
||||
var secondResults = new List<string>();
|
||||
await foreach (var line in service.SearchAsync("acme", secondPage))
|
||||
{
|
||||
secondResults.Add(line);
|
||||
}
|
||||
|
||||
if (secondResults.Any(r => r.Contains("\"type\":\"node\"")))
|
||||
{
|
||||
var firstNodeLine = results.First(r => r.Contains("\"type\":\"node\""));
|
||||
var secondNodeLine = secondResults.First(r => r.Contains("\"type\":\"node\""));
|
||||
Assert.NotEqual(ExtractNodeId(firstNodeLine), ExtractNodeId(secondNodeLine));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_PrefersExactThenPrefixThenContains()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:t:component:example", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/example@1.0.0" } },
|
||||
new NodeTile { Id = "gn:t:component:example-lib", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/example-lib@1.0.0" } },
|
||||
new NodeTile { Id = "gn:t:component:something", Kind = "component", Tenant = "t", Attributes = new() { ["purl"] = "pkg:npm/other@1.0.0" } },
|
||||
});
|
||||
var service = CreateService(repo);
|
||||
var req = new GraphSearchRequest { Kinds = new[] { "component" }, Query = "example", Limit = 2 };
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.SearchAsync("t", req))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Contains(secondResults, r => r.Contains("\"type\":\"node\""));
|
||||
Assert.Contains("gn:t:component:example", lines.First());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_RespectsTileBudgetAndEmitsCursor()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:three", Kind = "component", Tenant = "acme" },
|
||||
}, Array.Empty<EdgeTile>());
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
Limit = 3,
|
||||
Budget = new GraphQueryBudget { Tiles = 2 }
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
var nodeCount = lines.Count(l => l.Contains("\"type\":\"node\""));
|
||||
Assert.True(lines.Count <= 2);
|
||||
Assert.True(nodeCount <= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_HonorsNodeAndEdgeBudgets()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" },
|
||||
}, new[]
|
||||
{
|
||||
new EdgeTile { Id = "ge:acme:one-two", Kind = "depends_on", Tenant = "acme", Source = "gn:acme:component:one", Target = "gn:acme:component:two" }
|
||||
});
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
IncludeEdges = true,
|
||||
Budget = new GraphQueryBudget { Tiles = 3, Nodes = 1, Edges = 1 }
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.True(lines.Count <= 3);
|
||||
Assert.Equal(1, lines.Count(l => l.Contains("\"type\":\"node\"")));
|
||||
Assert.Equal(1, lines.Count(l => l.Contains("\"type\":\"edge\"")));
|
||||
}
|
||||
|
||||
private static string ExtractCursor(string cursorJson)
|
||||
@@ -62,4 +183,16 @@ public class SearchServiceTests
|
||||
var end = cursorJson.IndexOf('"', start);
|
||||
return end > start ? cursorJson[start..end] : string.Empty;
|
||||
}
|
||||
|
||||
private static string ExtractNodeId(string nodeJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(nodeJson);
|
||||
return doc.RootElement.GetProperty("data").GetProperty("id").GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static InMemoryGraphSearchService CreateService(InMemoryGraphRepository? repository = null)
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
return new InMemoryGraphSearchService(repository ?? new InMemoryGraphRepository(), cache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Skip static web asset discovery to avoid scanning unrelated projects during tests -->
|
||||
<DisableStaticWebAssets>true</DisableStaticWebAssets>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Graph.Api/StellaOps.Graph.Api.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user