feat: Add Scanner CI runner and related artifacts
- Implemented `run-scanner-ci.sh` to build and run tests for the Scanner solution with a warmed NuGet cache. - Created `excititor-vex-traces.json` dashboard for monitoring Excititor VEX observations. - Added Docker Compose configuration for the OTLP span sink in `docker-compose.spansink.yml`. - Configured OpenTelemetry collector in `otel-spansink.yaml` to receive and process traces. - Developed `run-spansink.sh` script to run the OTLP span sink for Excititor traces. - Introduced `FileSystemRiskBundleObjectStore` for storing risk bundle artifacts in the filesystem. - Built `RiskBundleBuilder` for creating risk bundles with associated metadata and providers. - Established `RiskBundleJob` to execute the risk bundle creation and storage process. - Defined models for risk bundle inputs, entries, and manifests in `RiskBundleModels.cs`. - Implemented signing functionality for risk bundle manifests with `HmacRiskBundleManifestSigner`. - Created unit tests for `RiskBundleBuilder`, `RiskBundleJob`, and signing functionality to ensure correctness. - Added filesystem artifact reader tests to validate manifest parsing and artifact listing. - Included test manifests for egress scenarios in the task runner tests. - Developed timeline query service tests to verify tenant and event ID handling.
This commit is contained in:
@@ -3,7 +3,7 @@ namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
/// <summary>
|
||||
/// Query filters for timeline listing.
|
||||
/// </summary>
|
||||
public sealed class TimelineQueryOptions
|
||||
public sealed record TimelineQueryOptions
|
||||
{
|
||||
public string? EventType { get; init; }
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
@@ -27,4 +27,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<TimelineIndexerMigrationRunner>();
|
||||
services.AddScoped<ITimelineEventStore, TimelineEventStore>();
|
||||
services.AddScoped<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddScoped<ITimel
|
||||
services.AddScoped<ITimelineQueryStore, TimelineQueryStore>();
|
||||
services.AddScoped<ITimelineQueryService, TimelineQueryService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ public class TimelineIngestionServiceTests
|
||||
RawPayloadJson = """{"ok":true}"""
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(envelope);
|
||||
var result = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Inserted);
|
||||
Assert.Equal("sha256:8ceeb2a3cfdd5c6c0257df04e3d6b7c29c6a54f9b89e0ee1d3f3f94a639a6a39", store.LastEnvelope?.PayloadHash);
|
||||
Assert.Equal("sha256:4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93", store.LastEnvelope?.PayloadHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -42,8 +42,8 @@ public class TimelineIngestionServiceTests
|
||||
RawPayloadJson = "{}"
|
||||
};
|
||||
|
||||
var first = await service.IngestAsync(envelope);
|
||||
var second = await service.IngestAsync(envelope);
|
||||
var first = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
var second = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(first.Inserted);
|
||||
Assert.False(second.Inserted);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Models.Results;
|
||||
@@ -21,7 +21,7 @@ public sealed class TimelineIngestionWorkerTests
|
||||
serviceCollection.AddSingleton<ITimelineEventStore>(store);
|
||||
serviceCollection.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
serviceCollection.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
serviceCollection.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
|
||||
serviceCollection.AddLogging();
|
||||
|
||||
using var host = serviceCollection.BuildServiceProvider();
|
||||
var hosted = host.GetRequiredService<IHostedService>();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Core.Services;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public class TimelineQueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_ClampsLimit()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
var options = new TimelineQueryOptions { Limit = 2000 };
|
||||
|
||||
await service.QueryAsync("tenant-a", options, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(500, store.LastOptions?.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_PassesTenantAndId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
await service.GetAsync("tenant-1", "evt-1", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineQueryStore
|
||||
{
|
||||
public TimelineQueryOptions? LastOptions { get; private set; }
|
||||
public (string tenant, string id)? LastGet { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
LastOptions = options;
|
||||
return Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
|
||||
}
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastGet = (tenantId, eventId);
|
||||
return Task.FromResult<TimelineEventView?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,58 +4,39 @@ namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
public sealed class TimelineSchemaTests
|
||||
{
|
||||
private static string FindRepoRoot()
|
||||
private static string FindMigrationPath()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 10 && dir is not null; i++)
|
||||
for (var i = 0; i < 12 && dir is not null; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "StellaOps.sln")) ||
|
||||
File.Exists(Path.Combine(dir, "Directory.Build.props")))
|
||||
var candidate = Path.Combine(dir, "Db", "Migrations", "001_initial_schema.sql");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return dir;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var infraCandidate = Path.Combine(dir, "StellaOps.TimelineIndexer.Infrastructure", "Db", "Migrations", "001_initial_schema.sql");
|
||||
if (File.Exists(infraCandidate))
|
||||
{
|
||||
return infraCandidate;
|
||||
}
|
||||
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not locate repository root from test base directory.");
|
||||
throw new FileNotFoundException("Expected migration file was not found after traversing upward.", "(Db/Migrations/001_initial_schema.sql)");
|
||||
}
|
||||
|
||||
private static string ReadMigrationSql()
|
||||
{
|
||||
var root = FindRepoRoot();
|
||||
var path = Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer.Infrastructure",
|
||||
"Db",
|
||||
"Migrations",
|
||||
"001_initial_schema.sql");
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("Expected migration file was not found.", path);
|
||||
}
|
||||
|
||||
var path = FindMigrationPath();
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationFile_Exists()
|
||||
{
|
||||
var root = FindRepoRoot();
|
||||
var path = Path.Combine(
|
||||
root,
|
||||
"src",
|
||||
"TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer",
|
||||
"StellaOps.TimelineIndexer.Infrastructure",
|
||||
"Db",
|
||||
"Migrations",
|
||||
"001_initial_schema.sql");
|
||||
|
||||
var path = FindMigrationPath();
|
||||
Assert.True(File.Exists(path), $"Migration script missing at {path}");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.DependencyInjection;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddEnvironmentVariables(prefix: "TIMELINE_");
|
||||
|
||||
builder.Services.AddTimelineIndexerPostgres(builder.Configuration);
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configure: options =>
|
||||
@@ -34,10 +44,64 @@ app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/timeline/events", () => Results.Ok(Array.Empty<object>()))
|
||||
app.MapGet("/timeline", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
[FromQuery] string? eventType,
|
||||
[FromQuery] string? correlationId,
|
||||
[FromQuery] string? traceId,
|
||||
[FromQuery] string? severity,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromQuery] long? after,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var options = new TimelineQueryOptions
|
||||
{
|
||||
EventType = eventType,
|
||||
CorrelationId = correlationId,
|
||||
TraceId = traceId,
|
||||
Severity = severity,
|
||||
Since = since,
|
||||
AfterEventSeq = after,
|
||||
Limit = limit ?? 100
|
||||
};
|
||||
var items = await service.QueryAsync(tenantId, options, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(items);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapGet("/timeline/{eventId}", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var item = await service.GetAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null ? Results.NotFound() : Results.Ok(item);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapPost("/timeline/events", () => Results.Accepted("/timeline/events", new { status = "indexed" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);
|
||||
|
||||
app.Run();
|
||||
|
||||
static string GetTenantId(HttpContext ctx)
|
||||
{
|
||||
// Temporary: allow explicit header override; fallback to claim if present.
|
||||
if (ctx.Request.Headers.TryGetValue("X-Tenant", out var header) && !string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header!;
|
||||
}
|
||||
|
||||
var tenant = ctx.User.FindFirst("tenant")?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return tenant!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Tenant not provided");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user