up
This commit is contained in:
@@ -7,4 +7,4 @@
|
||||
| OAS-62-001 | DOING | Populate request/response examples for top 50 endpoints, including standard error envelope. |
|
||||
| OAS-62-002 | TODO | Add custom lint rules enforcing pagination, idempotency headers, naming conventions, and example coverage. |
|
||||
| OAS-63-001 | TODO | Implement compatibility diff tooling comparing previous release specs; classify breaking vs additive changes. |
|
||||
| OAS-63-002 | TODO | Add `/.well-known/openapi` discovery endpoint schema metadata (extensions, version info). |
|
||||
| OAS-63-002 | DONE (2025-11-24) | Discovery endpoint metadata and schema extensions added; composed spec exports `/.well-known/openapi` entry. |
|
||||
|
||||
@@ -60,7 +60,9 @@ app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest requ
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.SearchAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.SearchAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -113,7 +115,9 @@ app.MapPost("/graph/query", async (HttpContext context, GraphQueryRequest reques
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.QueryAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.QueryAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -166,7 +170,9 @@ app.MapPost("/graph/paths", async (HttpContext context, GraphPathRequest request
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.FindPathsAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.FindPathsAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -219,7 +225,9 @@ app.MapPost("/graph/diff", async (HttpContext context, GraphDiffRequest request,
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var line in service.DiffAsync(tenant!, request, ct))
|
||||
var tenantId = tenant!;
|
||||
|
||||
await foreach (var line in service.DiffAsync(tenantId, request, ct))
|
||||
{
|
||||
await context.Response.WriteAsync(line, ct);
|
||||
await context.Response.WriteAsync("\n", ct);
|
||||
@@ -272,7 +280,8 @@ app.MapPost("/graph/export", async (HttpContext context, GraphExportRequest requ
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
var job = await service.StartExportAsync(tenant!, request, ct);
|
||||
var tenantId = tenant!;
|
||||
var job = await service.StartExportAsync(tenantId, request, ct);
|
||||
var manifest = new
|
||||
{
|
||||
jobId = job.JobId,
|
||||
|
||||
@@ -8,14 +8,16 @@ namespace StellaOps.Graph.Api.Services;
|
||||
public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphDiffService(InMemoryGraphRepository repository)
|
||||
public InMemoryGraphDiffService(InMemoryGraphRepository repository, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> DiffAsync(string tenant, GraphDiffRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
@@ -26,6 +28,7 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
var edgeBudgetRemaining = budget.Edges ?? 10000;
|
||||
var budgetRemaining = tileBudgetLimit;
|
||||
var seq = 0;
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var snapA = _repository.GetSnapshot(tenant, request.SnapshotA);
|
||||
var snapB = _repository.GetSnapshot(tenant, request.SnapshotB);
|
||||
@@ -39,6 +42,8 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
Details = new { request.SnapshotA, request.SnapshotB }
|
||||
};
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/diff"));
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -49,15 +54,15 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
|
||||
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);
|
||||
}
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "nodes")); 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);
|
||||
}
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "nodes")); 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))
|
||||
{
|
||||
@@ -65,7 +70,7 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
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; }
|
||||
if (!Spend(ref budgetRemaining, ref nodeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "nodes")); yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "node",
|
||||
@@ -82,13 +87,13 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
{
|
||||
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; }
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges")); 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; }
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges")); yield return tile!; yield break; }
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("edge_removed", seq++, removed, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
@@ -98,12 +103,12 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
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,
|
||||
if (!Spend(ref budgetRemaining, ref edgeBudgetRemaining, tileBudgetLimit, seq, out var tile)) { _metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "edges")); yield return tile!; yield break; }
|
||||
var diff = new DiffTile
|
||||
{
|
||||
EntityType = "edge",
|
||||
ChangeType = "changed",
|
||||
Id = common,
|
||||
Before = a,
|
||||
After = b
|
||||
};
|
||||
@@ -126,6 +131,9 @@ public sealed class InMemoryGraphDiffService : IGraphDiffService
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/diff"));
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,17 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
private readonly InMemoryGraphRepository _repository;
|
||||
private readonly IOverlayService _overlayService;
|
||||
private readonly IGraphMetrics _metrics;
|
||||
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService)
|
||||
public InMemoryGraphPathService(InMemoryGraphRepository repository, IOverlayService overlayService, IGraphMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_overlayService = overlayService;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> FindPathsAsync(string tenant, GraphPathRequest request, [EnumeratorCancellation] CancellationToken ct = default)
|
||||
@@ -29,6 +31,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
var edgeBudgetRemaining = budget.Edges ?? 10000;
|
||||
var budgetRemaining = tileBudgetLimit;
|
||||
var seq = 0;
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var result = FindShortestPath(tenant, request, maxDepth);
|
||||
|
||||
@@ -42,6 +45,8 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
};
|
||||
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("error", seq++, error, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/paths"));
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -58,6 +63,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
if (budgetRemaining <= 0 || nodeBudgetRemaining <= 0)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "tiles"));
|
||||
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
|
||||
yield break;
|
||||
}
|
||||
@@ -75,6 +81,7 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
{
|
||||
if (budgetRemaining <= 0 || edgeBudgetRemaining <= 0)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "tiles"));
|
||||
yield return BudgetExceeded(tileBudgetLimit, budgetRemaining, seq++);
|
||||
yield break;
|
||||
}
|
||||
@@ -93,6 +100,9 @@ public sealed class InMemoryGraphPathService : IGraphPathService
|
||||
yield return JsonSerializer.Serialize(new TileEnvelope("stats", seq++, stats, Cost(tileBudgetLimit, budgetRemaining)), Options);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
_metrics.QueryLatencySeconds.Record(stopwatch.Elapsed.TotalSeconds, new KeyValuePair<string, object?>("route", "/graph/paths"));
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,21 +49,17 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
var cursorOffset = CursorCodec.Decode(request.Cursor);
|
||||
var (nodes, edges) = _repository.QueryGraph(tenant, request);
|
||||
|
||||
if (nodes.Count > nodeBudgetLimit)
|
||||
{
|
||||
_metrics.BudgetDenied.Add(1, new KeyValuePair<string, object?>("reason", "nodes"));
|
||||
yield return BuildBudgetError(cacheKey, "nodes", nodeBudgetLimit, nodes.Count, edges.Count, budget);
|
||||
yield break;
|
||||
}
|
||||
|
||||
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 return BuildBudgetError(cacheKey, "edges", edgeBudgetLimit, nodes.Count, edges.Count, budget);
|
||||
yield break;
|
||||
}
|
||||
|
||||
@@ -206,4 +202,20 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
|
||||
private static CostBudget Cost(int limit, int remainingBudget) =>
|
||||
new(limit, remainingBudget - 1, limit - (remainingBudget - 1));
|
||||
|
||||
private string BuildBudgetError(string cacheKey, string exceeded, int budgetLimit, int nodesCount, int edgesCount, GraphQueryBudget budget)
|
||||
{
|
||||
var error = new ErrorResponse
|
||||
{
|
||||
Error = "GRAPH_BUDGET_EXCEEDED",
|
||||
Message = $"Query exceeded {exceeded} budget ({exceeded}>{budgetLimit}).",
|
||||
Details = new { nodes = nodesCount, edges = edgesCount, budget }
|
||||
};
|
||||
var errorLine = JsonSerializer.Serialize(new TileEnvelope("error", 0, error), Options);
|
||||
_cache.Set(cacheKey, new[] { errorLine }, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
return errorLine;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace StellaOps.Graph.Api.Services;
|
||||
}
|
||||
|
||||
// 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);
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase!, StringComparer.Ordinal);
|
||||
|
||||
if (sampleExplain && !explainEmitted)
|
||||
{
|
||||
|
||||
@@ -11,7 +11,8 @@ public class DiffServiceTests
|
||||
public async Task DiffAsync_EmitsAddedRemovedChangedAndStats()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphDiffService(repo);
|
||||
var metrics = new GraphMetrics();
|
||||
var service = new InMemoryGraphDiffService(repo, metrics);
|
||||
|
||||
var request = new GraphDiffRequest
|
||||
{
|
||||
@@ -37,7 +38,8 @@ public class DiffServiceTests
|
||||
public async Task DiffAsync_WhenSnapshotMissing_ReturnsError()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = new InMemoryGraphDiffService(repo);
|
||||
var metrics = new GraphMetrics();
|
||||
var service = new InMemoryGraphDiffService(repo, metrics);
|
||||
|
||||
var request = new GraphDiffRequest
|
||||
{
|
||||
|
||||
@@ -13,8 +13,9 @@ public class PathServiceTests
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphPathService(repo, overlays);
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphPathService(repo, overlays, metrics);
|
||||
|
||||
var request = new GraphPathRequest
|
||||
{
|
||||
@@ -39,8 +40,9 @@ public class PathServiceTests
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphPathService(repo, overlays);
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphPathService(repo, overlays, metrics);
|
||||
|
||||
var request = new GraphPathRequest
|
||||
{
|
||||
|
||||
@@ -107,8 +107,9 @@ namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
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);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
return new InMemoryGraphQueryService(repository ?? new InMemoryGraphRepository(), cache, overlays, metrics);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +121,9 @@ public class SearchServiceTests
|
||||
}, Array.Empty<EdgeTile>());
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
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" },
|
||||
@@ -154,8 +155,9 @@ public class SearchServiceTests
|
||||
});
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
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" },
|
||||
|
||||
12
src/Policy/StellaOps.Policy.min.slnf
Normal file
12
src/Policy/StellaOps.Policy.min.slnf
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "StellaOps.Policy.sln",
|
||||
"projects": [
|
||||
"__Libraries/StellaOps.Policy/StellaOps.Policy.csproj",
|
||||
"__Tests/StellaOps.Policy.Tests/StellaOps.Policy.Tests.csproj",
|
||||
"../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj",
|
||||
"../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj",
|
||||
"../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
9
src/Policy/StellaOps.Policy.tests.slnf
Normal file
9
src/Policy/StellaOps.Policy.tests.slnf
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"solution": {
|
||||
"path": "StellaOps.Policy.sln",
|
||||
"projects": [
|
||||
"__Libraries/StellaOps.Policy/StellaOps.Policy.csproj",
|
||||
"__Tests/StellaOps.Policy.Tests/StellaOps.Policy.Tests.csproj"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Entropy;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Serialization;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
@@ -185,6 +186,41 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
RegisterArtifact: true));
|
||||
}
|
||||
|
||||
if (context.Analysis.TryGet<EntropyReport>(ScanAnalysisKeys.EntropyReport, out var entropyReport) && entropyReport is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(entropyReport, JsonOptions);
|
||||
payloads.Add(new SurfaceManifestPayload(
|
||||
ArtifactDocumentType.SurfaceObservation,
|
||||
ArtifactDocumentFormat.ObservationJson,
|
||||
Kind: "entropy.report",
|
||||
MediaType: "application/json",
|
||||
Content: Encoding.UTF8.GetBytes(json),
|
||||
View: "entropy",
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["imageDigest"] = entropyReport.ImageDigest,
|
||||
["layerDigest"] = entropyReport.LayerDigest,
|
||||
["opaqueRatio"] = entropyReport.ImageOpaqueRatio.ToString("0.####", CultureInfoInvariant)
|
||||
}));
|
||||
}
|
||||
|
||||
if (context.Analysis.TryGet<EntropyLayerSummary>(ScanAnalysisKeys.EntropyLayerSummary, out var entropySummary) && entropySummary is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(entropySummary, JsonOptions);
|
||||
payloads.Add(new SurfaceManifestPayload(
|
||||
ArtifactDocumentType.SurfaceObservation,
|
||||
ArtifactDocumentFormat.ObservationJson,
|
||||
Kind: "entropy.layer-summary",
|
||||
MediaType: "application/json",
|
||||
Content: Encoding.UTF8.GetBytes(json),
|
||||
View: "entropy",
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["layerDigest"] = entropySummary.LayerDigest,
|
||||
["opaqueRatio"] = entropySummary.OpaqueRatio.ToString("0.####", CultureInfoInvariant)
|
||||
}));
|
||||
}
|
||||
|
||||
return payloads;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,4 +28,8 @@ public static class ScanAnalysisKeys
|
||||
|
||||
public const string ReachabilityUnionGraph = "analysis.reachability.union.graph";
|
||||
public const string ReachabilityUnionCas = "analysis.reachability.union.cas";
|
||||
|
||||
public const string FileEntries = "analysis.files.entries";
|
||||
public const string EntropyReport = "analysis.entropy.report";
|
||||
public const string EntropyLayerSummary = "analysis.entropy.layer.summary";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Backend;
|
||||
|
||||
internal interface IRuntimeFactsClient
|
||||
{
|
||||
Task PublishAsync(RuntimeFactsPublishRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeFactsClient : IRuntimeFactsClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
|
||||
private readonly ILogger<RuntimeFactsClient> logger;
|
||||
|
||||
public RuntimeFactsClient(
|
||||
HttpClient httpClient,
|
||||
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
|
||||
ILogger<RuntimeFactsClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PublishAsync(RuntimeFactsPublishRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var reachability = observerOptions.CurrentValue.Reachability ?? new ReachabilityRuntimeOptions();
|
||||
var endpoint = reachability.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
throw new RuntimeFactsException("Reachability endpoint is not configured.");
|
||||
}
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, endpoint);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reachability.AnalysisId))
|
||||
{
|
||||
message.Headers.TryAddWithoutValidation("X-Analysis-Id", reachability.AnalysisId);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(request, SerializerOptions);
|
||||
message.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
using var response = await httpClient.SendAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogWarning("Runtime facts publish failed with status {Status}: {Body}", (int)response.StatusCode, body);
|
||||
throw new RuntimeFactsException($"Runtime facts publish failed with status {(int)response.StatusCode}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for emitting runtime reachability facts to Signals.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityRuntimeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables runtime reachability fact publishing when true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signals endpoint that accepts runtime facts (JSON or NDJSON).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Endpoint { get; set; } = "https://signals.internal/signals/runtime-facts";
|
||||
|
||||
/// <summary>
|
||||
/// Required callgraph identifier used to correlate runtime facts with static graphs.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string CallgraphId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional analysis identifier forwarded as X-Analysis-Id.
|
||||
/// </summary>
|
||||
public string? AnalysisId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of facts sent in a single publish attempt.
|
||||
/// </summary>
|
||||
[Range(1, 5000)]
|
||||
public int BatchSize { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delay (seconds) before flushing a partially filled batch.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "0.1", "30")]
|
||||
public double FlushIntervalSeconds { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Optional subject fallback when image data is missing.
|
||||
/// </summary>
|
||||
public string? SubjectScanId { get; set; }
|
||||
|
||||
public string? SubjectImageDigest { get; set; }
|
||||
|
||||
public string? SubjectComponent { get; set; }
|
||||
|
||||
public string? SubjectVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace StellaOps.Zastava.Observer.Configuration;
|
||||
/// <summary>
|
||||
/// Observer-specific configuration applied on top of the shared runtime options.
|
||||
/// </summary>
|
||||
public sealed class ZastavaObserverOptions
|
||||
{
|
||||
public sealed class ZastavaObserverOptions
|
||||
{
|
||||
public const string SectionName = "zastava:observer";
|
||||
|
||||
private const string DefaultContainerdSocket = "unix:///run/containerd/containerd.sock";
|
||||
@@ -126,6 +126,12 @@ public sealed class ZastavaObserverOptions
|
||||
/// </summary>
|
||||
[Range(1, 128)]
|
||||
public int MaxEntrypointArguments { get; set; } = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime reachability fact publishing configuration.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ReachabilityRuntimeOptions Reachability { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaObserverBackendOptions
|
||||
|
||||
@@ -87,6 +87,14 @@ public static class ObserverServiceCollectionExtensions
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(backend.RequestTimeoutSeconds, 1, 120));
|
||||
});
|
||||
|
||||
services.AddHttpClient<IRuntimeFactsClient, RuntimeFactsClient>()
|
||||
.ConfigureHttpClient((provider, client) =>
|
||||
{
|
||||
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<ZastavaObserverOptions>>();
|
||||
var observer = optionsMonitor.CurrentValue;
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(observer.Backend.RequestTimeoutSeconds, 1, 120));
|
||||
});
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<ZastavaRuntimeOptions>, ObserverRuntimeOptionsPostConfigure>());
|
||||
|
||||
// Surface environment + cache/manifest/secrets wiring
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Runtime;
|
||||
|
||||
internal static class RuntimeFactsBuilder
|
||||
{
|
||||
public static RuntimeFactsPublishRequest? Build(
|
||||
IReadOnlyCollection<RuntimeEventEnvelope> envelopes,
|
||||
ReachabilityRuntimeOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelopes);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (!options.Enabled || envelopes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.CallgraphId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var facts = new List<RuntimeFactEventPayload>(envelopes.Count);
|
||||
ReachabilitySubjectPayload? subject = null;
|
||||
|
||||
foreach (var envelope in envelopes)
|
||||
{
|
||||
var fact = TryBuildFact(envelope);
|
||||
if (fact is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
facts.Add(fact);
|
||||
subject ??= BuildSubject(envelope, options);
|
||||
}
|
||||
|
||||
if (facts.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
subject ??= BuildFallbackSubject(options);
|
||||
if (subject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
facts.Sort(CompareFacts);
|
||||
|
||||
return new RuntimeFactsPublishRequest
|
||||
{
|
||||
CallgraphId = options.CallgraphId.Trim(),
|
||||
Subject = subject,
|
||||
Events = facts
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeFactEventPayload? TryBuildFact(RuntimeEventEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
var evt = envelope.Event;
|
||||
if (evt.Kind != RuntimeEventKind.ContainerStart || evt.Process is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var symbolId = BuildSymbolId(evt.Process);
|
||||
if (string.IsNullOrWhiteSpace(symbolId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fact = new RuntimeFactEventPayload
|
||||
{
|
||||
SymbolId = symbolId,
|
||||
CodeId = Normalize(evt.Process.BuildId),
|
||||
BuildId = Normalize(evt.Process.BuildId),
|
||||
LoaderBase = null,
|
||||
Purl = null,
|
||||
SymbolDigest = null,
|
||||
HitCount = 1,
|
||||
ObservedAt = evt.When,
|
||||
ProcessId = evt.Process.Pid,
|
||||
ProcessName = ResolveProcessName(evt.Process.Entrypoint),
|
||||
ContainerId = evt.Workload.ContainerId,
|
||||
Metadata = BuildMetadata(evt.Process)
|
||||
};
|
||||
|
||||
return fact;
|
||||
}
|
||||
|
||||
private static string? BuildSymbolId(RuntimeProcess process)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(process.BuildId))
|
||||
{
|
||||
return $"sym:binary:{process.BuildId.Trim().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
var trace = process.EntryTrace?.FirstOrDefault(t =>
|
||||
!string.IsNullOrWhiteSpace(t.Target) || !string.IsNullOrWhiteSpace(t.File));
|
||||
|
||||
var seed = trace?.Target ?? trace?.File ?? process.Entrypoint.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(seed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var stable = ComputeStableFragment(seed);
|
||||
return $"sym:shell:{stable}";
|
||||
}
|
||||
|
||||
private static ReachabilitySubjectPayload? BuildSubject(RuntimeEventEnvelope envelope, ReachabilityRuntimeOptions options)
|
||||
{
|
||||
var workload = envelope.Event.Workload;
|
||||
var imageRef = workload.ImageRef;
|
||||
|
||||
var digest = ExtractImageDigest(imageRef);
|
||||
var (component, version) = ExtractComponentAndVersion(imageRef);
|
||||
|
||||
digest ??= Normalize(options.SubjectImageDigest);
|
||||
component ??= Normalize(options.SubjectComponent);
|
||||
version ??= Normalize(options.SubjectVersion);
|
||||
|
||||
var scanId = Normalize(options.SubjectScanId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(scanId)
|
||||
&& (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(version)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReachabilitySubjectPayload
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = digest,
|
||||
Component = component,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubjectPayload? BuildFallbackSubject(ReachabilityRuntimeOptions options)
|
||||
{
|
||||
var digest = Normalize(options.SubjectImageDigest);
|
||||
var component = Normalize(options.SubjectComponent);
|
||||
var version = Normalize(options.SubjectVersion);
|
||||
var scanId = Normalize(options.SubjectScanId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(digest) && string.IsNullOrWhiteSpace(scanId)
|
||||
&& (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(version)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReachabilitySubjectPayload
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = digest,
|
||||
Component = component,
|
||||
Version = version
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?>? BuildMetadata(RuntimeProcess process)
|
||||
{
|
||||
if (process.EntryTrace is null || process.EntryTrace.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var trace in process.EntryTrace)
|
||||
{
|
||||
if (trace is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var op = string.IsNullOrWhiteSpace(trace.Op) ? "exec" : trace.Op;
|
||||
var target = trace.Target ?? trace.File;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append(" | ");
|
||||
}
|
||||
sb.Append(op).Append(':').Append(target);
|
||||
}
|
||||
|
||||
if (sb.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["entryTrace"] = sb.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractImageDigest(string? imageRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var digestStart = imageRef.IndexOf("sha256:", StringComparison.OrdinalIgnoreCase);
|
||||
if (digestStart >= 0)
|
||||
{
|
||||
return imageRef[digestStart..].Trim();
|
||||
}
|
||||
|
||||
var atIndex = imageRef.IndexOf('@');
|
||||
if (atIndex > 0 && atIndex + 1 < imageRef.Length)
|
||||
{
|
||||
return imageRef[(atIndex + 1)..].Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string? Component, string? Version) ExtractComponentAndVersion(string? imageRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageRef))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var lastSlash = imageRef.LastIndexOf('/');
|
||||
var lastColon = imageRef.LastIndexOf(':');
|
||||
|
||||
if (lastColon < 0 || lastColon < lastSlash)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var component = imageRef[(lastSlash + 1)..lastColon];
|
||||
var version = imageRef[(lastColon + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
return (component.Trim(), version.Trim());
|
||||
}
|
||||
|
||||
private static string? ResolveProcessName(IReadOnlyList<string>? entrypoint)
|
||||
{
|
||||
if (entrypoint is null || entrypoint.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var first = entrypoint[0];
|
||||
if (string.IsNullOrWhiteSpace(first))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lastSlash = first.LastIndexOf('/');
|
||||
return lastSlash >= 0 ? first[(lastSlash + 1)..] : first;
|
||||
}
|
||||
|
||||
private static string ComputeStableFragment(string seed)
|
||||
{
|
||||
var normalized = seed.Trim().ToLowerInvariant();
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
_ = SHA256.TryHashData(bytes, hash, out _);
|
||||
return Convert.ToHexString(hash[..16]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static int CompareFacts(RuntimeFactEventPayload left, RuntimeFactEventPayload right)
|
||||
{
|
||||
var timeComparison = Nullable.Compare(left.ObservedAt, right.ObservedAt);
|
||||
if (timeComparison != 0)
|
||||
{
|
||||
return timeComparison;
|
||||
}
|
||||
|
||||
return string.Compare(left.SymbolId, right.SymbolId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal sealed class RuntimeFactsPublishRequest
|
||||
{
|
||||
public ReachabilitySubjectPayload Subject { get; set; } = new();
|
||||
|
||||
public string CallgraphId { get; set; } = string.Empty;
|
||||
|
||||
public List<RuntimeFactEventPayload> Events { get; set; } = new();
|
||||
}
|
||||
|
||||
internal sealed class ReachabilitySubjectPayload
|
||||
{
|
||||
public string? ScanId { get; set; }
|
||||
|
||||
public string? ImageDigest { get; set; }
|
||||
|
||||
public string? Component { get; set; }
|
||||
|
||||
public string? Version { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimeFactEventPayload
|
||||
{
|
||||
public string SymbolId { get; set; } = string.Empty;
|
||||
|
||||
public string? CodeId { get; set; }
|
||||
|
||||
public string? SymbolDigest { get; set; }
|
||||
|
||||
public string? Purl { get; set; }
|
||||
|
||||
public string? BuildId { get; set; }
|
||||
|
||||
public string? LoaderBase { get; set; }
|
||||
|
||||
public int? ProcessId { get; set; }
|
||||
|
||||
public string? ProcessName { get; set; }
|
||||
|
||||
public string? SocketAddress { get; set; }
|
||||
|
||||
public string? ContainerId { get; set; }
|
||||
|
||||
public string? EvidenceUri { get; set; }
|
||||
|
||||
public int HitCount { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset? ObservedAt { get; set; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimeFactsException : Exception
|
||||
{
|
||||
public RuntimeFactsException(string message, Exception? innerException = null) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
{
|
||||
private readonly IRuntimeEventBuffer buffer;
|
||||
private readonly IRuntimeEventsClient eventsClient;
|
||||
private readonly IRuntimeFactsClient runtimeFactsClient;
|
||||
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<RuntimeEventDispatchService> logger;
|
||||
@@ -20,12 +21,14 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
public RuntimeEventDispatchService(
|
||||
IRuntimeEventBuffer buffer,
|
||||
IRuntimeEventsClient eventsClient,
|
||||
IRuntimeFactsClient runtimeFactsClient,
|
||||
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventDispatchService> logger)
|
||||
{
|
||||
this.buffer = buffer ?? throw new ArgumentNullException(nameof(buffer));
|
||||
this.eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient));
|
||||
this.runtimeFactsClient = runtimeFactsClient ?? throw new ArgumentNullException(nameof(runtimeFactsClient));
|
||||
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -116,14 +119,17 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new RuntimeEventsIngestRequest
|
||||
{
|
||||
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}",
|
||||
Events = batch.Select(item => item.Envelope).ToArray()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var envelopes = batch.Select(item => item.Envelope).ToArray();
|
||||
var factsPublished = await TryPublishRuntimeFactsAsync(envelopes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var request = new RuntimeEventsIngestRequest
|
||||
{
|
||||
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}",
|
||||
Events = envelopes
|
||||
};
|
||||
|
||||
var result = await eventsClient.PublishAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Success)
|
||||
{
|
||||
@@ -132,10 +138,11 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
await item.CompleteAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogInformation("Runtime events batch published (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}).",
|
||||
logger.LogInformation("Runtime events batch published (batchId={BatchId}, accepted={Accepted}, duplicates={Duplicates}, runtimeFacts={FactsPublished}).",
|
||||
request.BatchId,
|
||||
result.Accepted,
|
||||
result.Duplicates);
|
||||
result.Duplicates,
|
||||
factsPublished);
|
||||
}
|
||||
else if (result.RateLimited)
|
||||
{
|
||||
@@ -166,6 +173,38 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryPublishRuntimeFactsAsync(RuntimeEventEnvelope[] envelopes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (envelopes.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var options = observerOptions.CurrentValue.Reachability;
|
||||
var request = RuntimeFactsBuilder.Build(envelopes, options);
|
||||
if (request is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await runtimeFactsClient.PublishAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
logger.LogDebug("Published {Count} runtime facts (callgraphId={CallgraphId}).", request.Events.Count, request.CallgraphId);
|
||||
return true;
|
||||
}
|
||||
catch (RuntimeFactsException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(ex, "Runtime facts publish failed; batch will be retried.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(ex, "Runtime facts publish encountered an unexpected error; batch will be retried.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RequeueBatchAsync(IEnumerable<RuntimeEventBufferItem> batch, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var item in batch)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Tests.Runtime;
|
||||
|
||||
public sealed class RuntimeFactsBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_UsesBuildIdAndDigest()
|
||||
{
|
||||
var options = new ReachabilityRuntimeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
CallgraphId = "cg-001"
|
||||
};
|
||||
|
||||
var runtimeEvent = CreateRuntimeEvent(
|
||||
imageRef: "ghcr.io/example/api@sha256:deadbeef",
|
||||
buildId: "beadfeed",
|
||||
when: DateTimeOffset.Parse("2025-11-26T12:00:00Z"));
|
||||
|
||||
var envelope = RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent);
|
||||
var result = RuntimeFactsBuilder.Build(new[] { envelope }, options);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("cg-001", result!.CallgraphId);
|
||||
Assert.Equal("sha256:deadbeef", result.Subject.ImageDigest);
|
||||
Assert.Single(result.Events);
|
||||
Assert.StartsWith("sym:binary:beadfeed", result.Events[0].SymbolId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ParsesComponentAndVersion_WhenTagPresent()
|
||||
{
|
||||
var options = new ReachabilityRuntimeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
CallgraphId = "cg-002"
|
||||
};
|
||||
|
||||
var runtimeEvent = CreateRuntimeEvent(
|
||||
imageRef: "registry.local/team/web:1.2.3",
|
||||
buildId: null,
|
||||
when: DateTimeOffset.Parse("2025-11-26T12:01:00Z"));
|
||||
|
||||
var envelope = RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent);
|
||||
var result = RuntimeFactsBuilder.Build(new[] { envelope }, options);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("web", result!.Subject.Component);
|
||||
Assert.Equal("1.2.3", result.Subject.Version);
|
||||
Assert.Single(result.Events);
|
||||
Assert.StartsWith("sym:shell:", result.Events[0].SymbolId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static RuntimeEvent CreateRuntimeEvent(string imageRef, string? buildId, DateTimeOffset when)
|
||||
{
|
||||
var process = new RuntimeProcess
|
||||
{
|
||||
Pid = 1234,
|
||||
Entrypoint = new[] { "/entrypoint.sh", "-c", "echo hi" },
|
||||
EntryTrace = new[] { new RuntimeEntryTrace { File = "/entrypoint.sh", Op = "exec", Target = "/entrypoint.sh" } },
|
||||
BuildId = buildId
|
||||
};
|
||||
|
||||
return new RuntimeEvent
|
||||
{
|
||||
EventId = "evt-1",
|
||||
When = when,
|
||||
Kind = RuntimeEventKind.ContainerStart,
|
||||
Tenant = "tenant-a",
|
||||
Node = "node-a",
|
||||
Runtime = new RuntimeEngine { Engine = "containerd", Version = "1.7" },
|
||||
Workload = new RuntimeWorkload
|
||||
{
|
||||
Platform = "kubernetes",
|
||||
Namespace = "default",
|
||||
Pod = "pod-a",
|
||||
Container = "api",
|
||||
ContainerId = "containerd://abc",
|
||||
ImageRef = imageRef
|
||||
},
|
||||
Process = process,
|
||||
LoadedLibraries = Array.Empty<RuntimeLoadedLibrary>(),
|
||||
Evidence = Array.Empty<RuntimeEvidence>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user