up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
cryptopro-linux-csp / build-and-test (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
cryptopro-linux-csp / build-and-test (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -13,7 +13,7 @@ public sealed class SignalsEventsOptions
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Transport driver: "inmemory" or "redis".
|
||||
/// Transport driver: "inmemory", "redis", or "router".
|
||||
/// </summary>
|
||||
public string Driver { get; set; } = "inmemory";
|
||||
|
||||
@@ -62,6 +62,11 @@ public sealed class SignalsEventsOptions
|
||||
/// </summary>
|
||||
public string DefaultTenant { get; set; } = "tenant-default";
|
||||
|
||||
/// <summary>
|
||||
/// Router transport configuration (when Driver=router).
|
||||
/// </summary>
|
||||
public SignalsRouterEventsOptions Router { get; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
var normalizedDriver = Driver?.Trim();
|
||||
@@ -71,9 +76,10 @@ public sealed class SignalsEventsOptions
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedDriver, "redis", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(normalizedDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
|
||||
&& !string.Equals(normalizedDriver, "inmemory", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(normalizedDriver, "router", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Signals events driver must be 'redis' or 'inmemory'.");
|
||||
throw new InvalidOperationException("Signals events driver must be 'redis', 'router', or 'inmemory'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Stream))
|
||||
@@ -101,5 +107,23 @@ public sealed class SignalsEventsOptions
|
||||
{
|
||||
throw new InvalidOperationException("Signals events Redis driver requires ConnectionString.");
|
||||
}
|
||||
|
||||
if (string.Equals(normalizedDriver, "router", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Router.BaseUrl))
|
||||
{
|
||||
throw new InvalidOperationException("Signals events router driver requires BaseUrl.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Router.Path))
|
||||
{
|
||||
throw new InvalidOperationException("Signals events router driver requires Path.");
|
||||
}
|
||||
|
||||
if (Router.TimeoutSeconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("Signals events router timeout must be >= 0 seconds.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Router event transport configuration for reachability fact updates.
|
||||
/// </summary>
|
||||
public sealed class SignalsRouterEventsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL for the StellaOps Router gateway (HTTP ingress).
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "https://gateway.stella-ops.local";
|
||||
|
||||
/// <summary>
|
||||
/// Relative path that receives fact update envelopes.
|
||||
/// </summary>
|
||||
public string Path { get; set; } = "/router/events/signals.fact.updated";
|
||||
|
||||
/// <summary>
|
||||
/// Optional API key value used for gateway authentication.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name that carries the API key when set.
|
||||
/// </summary>
|
||||
public string ApiKeyHeader { get; set; } = "X-API-Key";
|
||||
|
||||
/// <summary>
|
||||
/// Optional additional header passed with every publish (key/value).
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Publish timeout in seconds. 0 disables the timeout.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Allow self-signed TLS when talking to the gateway (development only).
|
||||
/// </summary>
|
||||
public bool AllowInsecureTls { get; set; }
|
||||
}
|
||||
@@ -168,6 +168,31 @@ builder.Services.AddSingleton<IReachabilityCache>(sp =>
|
||||
});
|
||||
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
||||
builder.Services.AddSingleton<ReachabilityFactEventBuilder>();
|
||||
builder.Services.AddHttpClient<RouterEventsPublisher>((sp, client) =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<SignalsOptions>().Events.Router;
|
||||
if (Uri.TryCreate(opts.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
client.BaseAddress = baseUri;
|
||||
}
|
||||
|
||||
if (opts.TimeoutSeconds > 0)
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds);
|
||||
}
|
||||
|
||||
client.DefaultRequestHeaders.ConnectionClose = false;
|
||||
}).ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<SignalsOptions>().Events.Router;
|
||||
var handler = new HttpClientHandler();
|
||||
if (opts.AllowInsecureTls)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
builder.Services.AddSingleton<IEventsPublisher>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<SignalsOptions>();
|
||||
@@ -187,6 +212,11 @@ builder.Services.AddSingleton<IEventsPublisher>(sp =>
|
||||
sp.GetRequiredService<ILogger<RedisEventsPublisher>>());
|
||||
}
|
||||
|
||||
if (string.Equals(options.Events.Driver, "router", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return sp.GetRequiredService<RouterEventsPublisher>();
|
||||
}
|
||||
|
||||
return new InMemoryEventsPublisher(
|
||||
sp.GetRequiredService<ILogger<InMemoryEventsPublisher>>(),
|
||||
eventBuilder);
|
||||
|
||||
106
src/Signals/StellaOps.Signals/Services/RouterEventsPublisher.cs
Normal file
106
src/Signals/StellaOps.Signals/Services/RouterEventsPublisher.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Router-backed publisher placeholder. Emits envelopes to log until router event channel is provisioned.
|
||||
/// </summary>
|
||||
internal sealed class RouterEventsPublisher : IEventsPublisher
|
||||
{
|
||||
private readonly ReachabilityFactEventBuilder eventBuilder;
|
||||
private readonly SignalsOptions options;
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly ILogger<RouterEventsPublisher> logger;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public RouterEventsPublisher(
|
||||
ReachabilityFactEventBuilder eventBuilder,
|
||||
SignalsOptions options,
|
||||
HttpClient httpClient,
|
||||
ILogger<RouterEventsPublisher> logger)
|
||||
{
|
||||
this.eventBuilder = eventBuilder ?? throw new ArgumentNullException(nameof(eventBuilder));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fact);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var envelope = eventBuilder.Build(fact);
|
||||
|
||||
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, options.Events.Router.Path);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
request.Headers.TryAddWithoutValidation("X-Signals-Topic", envelope.Topic);
|
||||
request.Headers.TryAddWithoutValidation("X-Signals-Tenant", envelope.Tenant);
|
||||
request.Headers.TryAddWithoutValidation("X-Signals-Pipeline", options.Events.Pipeline);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Events.Router.ApiKey))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
string.IsNullOrWhiteSpace(options.Events.Router.ApiKeyHeader)
|
||||
? "X-API-Key"
|
||||
: options.Events.Router.ApiKeyHeader,
|
||||
options.Events.Router.ApiKey);
|
||||
}
|
||||
|
||||
foreach (var header in options.Events.Router.Headers)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogError(
|
||||
"Router publish failed for {Topic} with status {StatusCode}: {Body}",
|
||||
envelope.Topic,
|
||||
(int)response.StatusCode,
|
||||
Truncate(body, 256));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Router publish succeeded for {Topic} ({StatusCode})",
|
||||
envelope.Topic,
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Router publish failed for {Topic}", envelope.Topic);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxLength];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests;
|
||||
|
||||
public class RouterEventsPublisherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublishFactUpdatedAsync_SendsEnvelopeToRouter()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var handler = new StubHandler(HttpStatusCode.Accepted);
|
||||
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri(options.Events.Router.BaseUrl) };
|
||||
var logger = new ListLogger<RouterEventsPublisher>();
|
||||
var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System);
|
||||
var publisher = new RouterEventsPublisher(builder, options, httpClient, logger);
|
||||
|
||||
await publisher.PublishFactUpdatedAsync(CreateFact(), CancellationToken.None);
|
||||
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal(options.Events.Router.Path, request.Uri!.PathAndQuery);
|
||||
Assert.Equal("application/json", request.ContentType);
|
||||
Assert.Contains(options.Events.Router.ApiKeyHeader, request.Headers.Keys);
|
||||
|
||||
using var doc = JsonDocument.Parse(request.Body ?? "{}");
|
||||
Assert.Equal(options.Events.Stream, doc.RootElement.GetProperty("topic").GetString());
|
||||
Assert.Equal("signals.fact.updated@v1", doc.RootElement.GetProperty("version").GetString());
|
||||
Assert.Contains(logger.Messages, m => m.Contains("Router publish succeeded"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishFactUpdatedAsync_LogsFailure()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
|
||||
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri(options.Events.Router.BaseUrl) };
|
||||
var logger = new ListLogger<RouterEventsPublisher>();
|
||||
var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System);
|
||||
var publisher = new RouterEventsPublisher(builder, options, httpClient, logger);
|
||||
|
||||
await publisher.PublishFactUpdatedAsync(CreateFact(), CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Messages, m => m.Contains("Router publish failed"));
|
||||
}
|
||||
|
||||
private static SignalsOptions CreateOptions()
|
||||
{
|
||||
var options = new SignalsOptions();
|
||||
options.Events.Driver = "router";
|
||||
options.Events.Stream = "signals.fact.updated.v1";
|
||||
options.Events.Router.BaseUrl = "https://router.test";
|
||||
options.Events.Router.Path = "/router/events/signals.fact.updated";
|
||||
options.Events.Router.ApiKeyHeader = "X-Test-Key";
|
||||
options.Events.Router.ApiKey = "secret";
|
||||
return options;
|
||||
}
|
||||
|
||||
private static ReachabilityFactDocument CreateFact()
|
||||
{
|
||||
return new ReachabilityFactDocument
|
||||
{
|
||||
SubjectKey = "tenant:image@sha256:abc",
|
||||
CallgraphId = "cg-123",
|
||||
ComputedAt = DateTimeOffset.Parse("2025-12-10T00:00:00Z"),
|
||||
States = new List<ReachabilityStateDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Target = "pkg:pypi/django",
|
||||
Reachable = true,
|
||||
Confidence = 0.9,
|
||||
Bucket = "runtime",
|
||||
Weight = 0.45
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode statusCode;
|
||||
private readonly string? responseBody;
|
||||
|
||||
public List<CapturedRequest> Requests { get; } = new();
|
||||
|
||||
public StubHandler(HttpStatusCode statusCode, string? responseBody = null)
|
||||
{
|
||||
this.statusCode = statusCode;
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Requests.Add(new CapturedRequest
|
||||
{
|
||||
Uri = request.RequestUri,
|
||||
ContentType = request.Content?.Headers.ContentType?.MediaType,
|
||||
Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToArray()),
|
||||
Body = body
|
||||
});
|
||||
|
||||
var response = new HttpResponseMessage(statusCode);
|
||||
if (!string.IsNullOrEmpty(responseBody))
|
||||
{
|
||||
response.Content = new StringContent(responseBody);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CapturedRequest
|
||||
{
|
||||
public Uri? Uri { get; init; }
|
||||
public string? ContentType { get; init; }
|
||||
public Dictionary<string, string[]> Headers { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public string? Body { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ListLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Messages { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
Messages.Add(formatter(state, exception));
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user