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

This commit is contained in:
StellaOps Bot
2025-12-09 09:38:09 +02:00
parent bc0762e97d
commit 108d1c64b3
193 changed files with 7265 additions and 13029 deletions

View File

@@ -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.");
}
}
}
}

View File

@@ -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; }
}

View File

@@ -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);

View 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];
}
}