feat(signals,reachgraph,airgap,zastava): postgres runtime persistence
Cross-module truthful runtime persistence supporting the sprint_20260415 and sprint_20260416 cutovers. These modules have no single dedicated sprint owner in the current batch, but they unblock downstream wiring in Policy (reachability facts), ReachGraph (signals adapter), and the air-gap controller/time services. - Signals.Persistence: migration 003 runtime_canonical_tables; Postgres repos (callgraph + projection, reachability fact/store, deployment refs, graph metrics); DB context factory + service collection extensions. - Signals: swap in-memory callgraph/reachability repositories for Postgres wired via SignalsPersistenceExtensions; durable host tests. - ReachGraph.WebService: SignalsHttpAdapter + program wiring; host wiring + adapter tests. - AirGap.Controller: service-collection extensions + infrastructure wiring; endpoint + startup contract tests. - AirGap.Time: PostgresTimeAnchorStore + startup service; runtime contract + persistence tests. - AirGap.Persistence: persistence extensions. - Zastava: csproj cleanup (Observer + Core). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,13 @@ builder.Services.AddScoped<IReachGraphReplayService, ReachGraphReplayService>();
|
||||
// Reachability Core adapters and unified query interface
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
builder.Services.AddScoped<StellaOps.Reachability.Core.IReachGraphAdapter, ReachGraphStoreAdapter>();
|
||||
builder.Services.AddSingleton<StellaOps.Reachability.Core.ISignalsAdapter, InMemorySignalsAdapter>();
|
||||
builder.Services.AddHttpClient<StellaOps.Reachability.Core.ISignalsAdapter, SignalsHttpAdapter>((sp, client) =>
|
||||
{
|
||||
var baseUri = ResolveSignalsBaseUri(builder.Configuration);
|
||||
client.BaseAddress = baseUri;
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.ConnectionClose = false;
|
||||
});
|
||||
builder.Services.AddScoped<StellaOps.Reachability.Core.IReachabilityIndex, StellaOps.Reachability.Core.ReachabilityIndex>();
|
||||
|
||||
// Rate limiting
|
||||
@@ -153,6 +159,20 @@ app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.LoadTranslationsAsync();
|
||||
app.Run();
|
||||
|
||||
static Uri ResolveSignalsBaseUri(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var raw = configuration["Signals:BaseUrl"]
|
||||
?? configuration["STELLAOPS_SIGNALS_URL"]
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_SIGNALS_URL")
|
||||
?? "http://signals.stella-ops.local";
|
||||
|
||||
return Uri.TryCreate(raw, UriKind.Absolute, out var uri)
|
||||
? uri
|
||||
: new Uri("http://signals.stella-ops.local", UriKind.Absolute);
|
||||
}
|
||||
|
||||
// Make Program class accessible for integration testing
|
||||
namespace StellaOps.ReachGraph.WebService
|
||||
{
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Reachability.Core;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Signals-backed implementation of <see cref="ISignalsAdapter"/> for runtime observation facts.
|
||||
/// </summary>
|
||||
public sealed class SignalsHttpAdapter : ISignalsAdapter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SignalsHttpAdapter> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SignalsHttpAdapter"/> class.
|
||||
/// </summary>
|
||||
public SignalsHttpAdapter(
|
||||
HttpClient httpClient,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SignalsHttpAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<RuntimeReachabilityResult> QueryAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var fact = await GetFactAsync(artifactDigest, tenantId, ct).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - observationWindow;
|
||||
|
||||
if (fact is null)
|
||||
{
|
||||
return CreateNotFoundResult(symbol, artifactDigest, observationWindow, windowStart, now);
|
||||
}
|
||||
|
||||
var matchingFacts = FindMatchingRuntimeFacts(fact, symbol).ToList();
|
||||
var matchingState = FindMatchingState(fact, symbol);
|
||||
|
||||
if (matchingFacts.Count == 0 && matchingState is null)
|
||||
{
|
||||
return CreateNotFoundResult(symbol, artifactDigest, observationWindow, windowStart, now);
|
||||
}
|
||||
|
||||
var observedAt = matchingFacts
|
||||
.Select(f => f.ObservedAt)
|
||||
.Where(t => t.HasValue)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
|
||||
var contexts = matchingFacts
|
||||
.Select(MapContext)
|
||||
.Distinct()
|
||||
.Take(10)
|
||||
.ToImmutableArray();
|
||||
|
||||
var evidenceUris = matchingFacts
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f.EvidenceUri))
|
||||
.Select(f => f.EvidenceUri!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var hitCount = matchingFacts.Sum(f => Math.Max(0, f.HitCount));
|
||||
if (hitCount == 0 && matchingState?.Evidence?.RuntimeHits is { Count: > 0 })
|
||||
{
|
||||
hitCount = matchingState.Evidence.RuntimeHits.Count;
|
||||
}
|
||||
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
WasObserved = hitCount > 0 || matchingState?.Reachable == true,
|
||||
ObservationWindow = observationWindow,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = now,
|
||||
HitCount = hitCount,
|
||||
FirstSeen = observedAt.Count > 0 ? observedAt.Min() : matchingState?.LatticeTransitionAt,
|
||||
LastSeen = observedAt.Count > 0 ? observedAt.Max() : matchingState?.LatticeTransitionAt,
|
||||
Contexts = contexts,
|
||||
EvidenceUris = evidenceUris,
|
||||
AgentVersion = ExtractAgentVersion(fact)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> HasFactsAsync(string artifactDigest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
return await GetFactAsync(artifactDigest, tenantId, ct).ConfigureAwait(false) is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SignalsMetadata?> GetMetadataAsync(string artifactDigest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var fact = await GetFactAsync(artifactDigest, tenantId, ct).ConfigureAwait(false);
|
||||
if (fact is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var runtimeFacts = fact.RuntimeFacts ?? [];
|
||||
if (runtimeFacts.Count == 0 && fact.States.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var observationTimes = runtimeFacts
|
||||
.Select(f => f.ObservedAt)
|
||||
.Where(t => t.HasValue)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
|
||||
if (observationTimes.Count == 0)
|
||||
{
|
||||
observationTimes.Add(fact.ComputedAt);
|
||||
}
|
||||
|
||||
var environments = runtimeFacts
|
||||
.SelectMany(ExtractEnvironments)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new SignalsMetadata
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
TenantId = tenantId,
|
||||
EarliestObservation = observationTimes.Min(),
|
||||
LatestObservation = observationTimes.Max(),
|
||||
SymbolCount = runtimeFacts.Count > 0
|
||||
? runtimeFacts.Select(f => f.SymbolId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).Count()
|
||||
: fact.States.Count,
|
||||
TotalObservations = runtimeFacts.Sum(f => Math.Max(0, f.HitCount)),
|
||||
Environments = environments,
|
||||
AgentVersion = ExtractAgentVersion(fact)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SignalsFactDocumentDto?> GetFactAsync(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/signals/facts/{Uri.EscapeDataString(artifactDigest)}");
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Tenant-ID", tenantId);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SignalsFactDocumentDto>(payload, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize Signals reachability fact for {ArtifactDigest}.", artifactDigest);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<RuntimeFactDocumentDto> FindMatchingRuntimeFacts(SignalsFactDocumentDto fact, SymbolRef symbol)
|
||||
{
|
||||
return fact.RuntimeFacts?.Where(runtimeFact => MatchesSymbol(runtimeFact.SymbolId, symbol)) ?? [];
|
||||
}
|
||||
|
||||
private static ReachabilityStateDocumentDto? FindMatchingState(SignalsFactDocumentDto fact, SymbolRef symbol)
|
||||
{
|
||||
return fact.States?.FirstOrDefault(state => MatchesSymbol(state.Target, symbol));
|
||||
}
|
||||
|
||||
private static bool MatchesSymbol(string? candidate, SymbolRef symbol)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var symbolNames = new[]
|
||||
{
|
||||
symbol.CanonicalId,
|
||||
symbol.DisplayName,
|
||||
BuildSymbolFqn(symbol),
|
||||
symbol.Method
|
||||
};
|
||||
|
||||
return symbolNames.Any(name =>
|
||||
!string.IsNullOrWhiteSpace(name) &&
|
||||
string.Equals(candidate.Trim(), name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string BuildSymbolFqn(SymbolRef symbol)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(symbol.Namespace))
|
||||
{
|
||||
parts.Add(symbol.Namespace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(symbol.Type) && !string.Equals(symbol.Type, "_", StringComparison.Ordinal))
|
||||
{
|
||||
parts.Add(symbol.Type);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(symbol.Method))
|
||||
{
|
||||
parts.Add(symbol.Method);
|
||||
}
|
||||
|
||||
return string.Join('.', parts);
|
||||
}
|
||||
|
||||
private static global::StellaOps.Reachability.Core.ExecutionContext MapContext(RuntimeFactDocumentDto fact)
|
||||
{
|
||||
return new global::StellaOps.Reachability.Core.ExecutionContext
|
||||
{
|
||||
ContainerId = fact.ContainerId ?? fact.ProcessName,
|
||||
ProcessId = fact.ProcessId,
|
||||
Route = fact.SocketAddress,
|
||||
Environment = TryGetEnvironment(fact),
|
||||
Frequency = 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ExtractEnvironments(RuntimeFactDocumentDto fact)
|
||||
{
|
||||
var environment = TryGetEnvironment(fact);
|
||||
return string.IsNullOrWhiteSpace(environment)
|
||||
? []
|
||||
: [environment!];
|
||||
}
|
||||
|
||||
private static string? TryGetEnvironment(RuntimeFactDocumentDto fact)
|
||||
{
|
||||
if (fact.Metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fact.Metadata.TryGetValue("environment", out var environment) && !string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
return environment.Trim();
|
||||
}
|
||||
|
||||
if (fact.Metadata.TryGetValue("env", out var env) && !string.IsNullOrWhiteSpace(env))
|
||||
{
|
||||
return env.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractAgentVersion(SignalsFactDocumentDto fact)
|
||||
{
|
||||
if (fact.Metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return fact.Metadata.TryGetValue("agent.version", out var agentVersion) && !string.IsNullOrWhiteSpace(agentVersion)
|
||||
? agentVersion.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static RuntimeReachabilityResult CreateNotFoundResult(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd)
|
||||
{
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
WasObserved = false,
|
||||
ObservationWindow = observationWindow,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = windowEnd,
|
||||
HitCount = 0,
|
||||
FirstSeen = null,
|
||||
LastSeen = null,
|
||||
Contexts = ImmutableArray<global::StellaOps.Reachability.Core.ExecutionContext>.Empty,
|
||||
EvidenceUris = ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class SignalsFactDocumentDto
|
||||
{
|
||||
public string SubjectKey { get; init; } = string.Empty;
|
||||
|
||||
public string? CallgraphId { get; init; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; init; }
|
||||
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
public List<ReachabilityStateDocumentDto> States { get; init; } = [];
|
||||
|
||||
public List<RuntimeFactDocumentDto>? RuntimeFacts { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachabilityStateDocumentDto
|
||||
{
|
||||
public string Target { get; init; } = string.Empty;
|
||||
|
||||
public bool Reachable { get; init; }
|
||||
|
||||
public DateTimeOffset? LatticeTransitionAt { get; init; }
|
||||
|
||||
public ReachabilityEvidenceDocumentDto? Evidence { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachabilityEvidenceDocumentDto
|
||||
{
|
||||
public List<string> RuntimeHits { get; init; } = [];
|
||||
}
|
||||
|
||||
private sealed class RuntimeFactDocumentDto
|
||||
{
|
||||
public string SymbolId { get; init; } = string.Empty;
|
||||
|
||||
public string? CodeId { get; init; }
|
||||
|
||||
public string? SymbolDigest { get; init; }
|
||||
|
||||
public string? Purl { get; init; }
|
||||
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
public string? LoaderBase { get; init; }
|
||||
|
||||
public int? ProcessId { get; init; }
|
||||
|
||||
public string? ProcessName { get; init; }
|
||||
|
||||
public string? SocketAddress { get; init; }
|
||||
|
||||
public string? ContainerId { get; init; }
|
||||
|
||||
public string? EvidenceUri { get; init; }
|
||||
|
||||
public int HitCount { get; init; }
|
||||
|
||||
public DateTimeOffset? ObservedAt { get; init; }
|
||||
|
||||
public Dictionary<string, string?>? Metadata { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# StellaOps.ReachGraph.WebService Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260415_004_DOCS_runtime_data_plane_real_backend_cutover.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| SPRINT_20260405_011-XPORT-VALKEY | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named the ReachGraph Valkey client construction path. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReachGraph/StellaOps.ReachGraph.WebService/StellaOps.ReachGraph.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| RUNTIME-002 | DONE | `ISignalsAdapter` is now resolved from the durable `SignalsHttpAdapter` client path in live host mode; the full ReachGraph test project passed and in-memory adapter remains test-only. |
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using StellaOps.Reachability.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
public sealed class ReachGraphHostWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void Host_ResolvesSignalsHttpAdapter_InLiveMode()
|
||||
{
|
||||
using var factory = new ReachGraphTestFactory();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
|
||||
var adapter = scope.ServiceProvider.GetRequiredService<ISignalsAdapter>();
|
||||
|
||||
Assert.IsType<SignalsHttpAdapter>(adapter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using StellaOps.Reachability.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
public sealed class SignalsHttpAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsObservedResult_FromSignalsPayload()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Namespace = "MyApp",
|
||||
Type = "Service",
|
||||
Method = "Process"
|
||||
};
|
||||
|
||||
var symbolId = symbol.CanonicalId;
|
||||
var payload = new
|
||||
{
|
||||
subjectKey = "sha256:test",
|
||||
computedAt = timeProvider.GetUtcNow(),
|
||||
metadata = new Dictionary<string, string?>
|
||||
{
|
||||
["agent.version"] = "signals-agent/1.2.3"
|
||||
},
|
||||
states = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
target = symbolId,
|
||||
reachable = true,
|
||||
latticeTransitionAt = timeProvider.GetUtcNow().AddMinutes(-5),
|
||||
evidence = new
|
||||
{
|
||||
runtimeHits = new[] { symbolId }
|
||||
}
|
||||
}
|
||||
},
|
||||
runtimeFacts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbolId,
|
||||
hitCount = 7,
|
||||
observedAt = timeProvider.GetUtcNow().AddMinutes(-2),
|
||||
containerId = "container-a",
|
||||
processId = 42,
|
||||
socketAddress = "10.0.0.1:8080",
|
||||
evidenceUri = "stella://signals/runtime/default/sha256:test?symbol=" + Uri.EscapeDataString(symbolId),
|
||||
metadata = new Dictionary<string, string?>
|
||||
{
|
||||
["environment"] = "production"
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
symbolId,
|
||||
hitCount = 5,
|
||||
observedAt = timeProvider.GetUtcNow().AddMinutes(-1),
|
||||
containerId = "container-b",
|
||||
processId = 43,
|
||||
socketAddress = "10.0.0.1:8081",
|
||||
evidenceUri = "stella://signals/runtime/default/sha256:test?symbol=" + Uri.EscapeDataString(symbolId) + "&seq=2",
|
||||
metadata = new Dictionary<string, string?>
|
||||
{
|
||||
["environment"] = "staging"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var adapter = CreateAdapter(
|
||||
responseStatusCode: HttpStatusCode.OK,
|
||||
responseJson: JsonSerializer.Serialize(payload),
|
||||
timeProvider,
|
||||
out var handler);
|
||||
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:test", TimeSpan.FromDays(7), "tenant-a", CancellationToken.None);
|
||||
|
||||
result.WasObserved.Should().BeTrue();
|
||||
result.HitCount.Should().Be(12);
|
||||
result.FirstSeen.Should().Be(timeProvider.GetUtcNow().AddMinutes(-2));
|
||||
result.LastSeen.Should().Be(timeProvider.GetUtcNow().AddMinutes(-1));
|
||||
result.Contexts.Should().ContainSingle(c => c.ContainerId == "container-a");
|
||||
result.Contexts.Should().ContainSingle(c => c.ContainerId == "container-b");
|
||||
result.EvidenceUris.Should().Contain(uri => uri.Contains("symbol=", StringComparison.Ordinal));
|
||||
result.AgentVersion.Should().Be("signals-agent/1.2.3");
|
||||
|
||||
handler.LastRequest.Should().NotBeNull();
|
||||
handler.LastRequest!.RequestUri.Should().NotBeNull();
|
||||
handler.LastRequest.RequestUri!.AbsolutePath.Should().Be($"/signals/facts/{Uri.EscapeDataString("sha256:test")}");
|
||||
handler.LastRequest.Headers.TryGetValues("X-Tenant-ID", out var tenantValues).Should().BeTrue();
|
||||
tenantValues.Should().ContainSingle(value => value == "tenant-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsAggregateMetadata_FromSignalsPayload()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 4, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Purl = "pkg:nuget/test@1.0.0",
|
||||
Namespace = "MyApp",
|
||||
Type = "Service",
|
||||
Method = "Process"
|
||||
};
|
||||
|
||||
var symbolId = symbol.CanonicalId;
|
||||
var payload = new
|
||||
{
|
||||
subjectKey = "sha256:test",
|
||||
computedAt = timeProvider.GetUtcNow(),
|
||||
metadata = new Dictionary<string, string?>(),
|
||||
states = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
target = symbolId,
|
||||
reachable = true,
|
||||
latticeTransitionAt = timeProvider.GetUtcNow().AddMinutes(-5),
|
||||
evidence = new { runtimeHits = new[] { symbolId } }
|
||||
}
|
||||
},
|
||||
runtimeFacts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
symbolId,
|
||||
hitCount = 7,
|
||||
observedAt = timeProvider.GetUtcNow().AddMinutes(-2),
|
||||
containerId = "container-a",
|
||||
processId = 42,
|
||||
socketAddress = "10.0.0.1:8080",
|
||||
evidenceUri = "stella://signals/runtime/default/sha256:test?symbol=" + Uri.EscapeDataString(symbolId),
|
||||
metadata = new Dictionary<string, string?>
|
||||
{
|
||||
["environment"] = "production"
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
symbolId,
|
||||
hitCount = 5,
|
||||
observedAt = timeProvider.GetUtcNow().AddMinutes(-1),
|
||||
containerId = "container-b",
|
||||
processId = 43,
|
||||
socketAddress = "10.0.0.1:8081",
|
||||
evidenceUri = "stella://signals/runtime/default/sha256:test?symbol=" + Uri.EscapeDataString(symbolId) + "&seq=2",
|
||||
metadata = new Dictionary<string, string?>
|
||||
{
|
||||
["environment"] = "staging"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var adapter = CreateAdapter(
|
||||
responseStatusCode: HttpStatusCode.OK,
|
||||
responseJson: JsonSerializer.Serialize(payload),
|
||||
timeProvider,
|
||||
out _);
|
||||
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant-a", CancellationToken.None);
|
||||
|
||||
metadata.Should().NotBeNull();
|
||||
metadata!.ArtifactDigest.Should().Be("sha256:test");
|
||||
metadata.SymbolCount.Should().Be(1);
|
||||
metadata.TotalObservations.Should().Be(12);
|
||||
metadata.EarliestObservation.Should().Be(timeProvider.GetUtcNow().AddMinutes(-2));
|
||||
metadata.LatestObservation.Should().Be(timeProvider.GetUtcNow().AddMinutes(-1));
|
||||
metadata.Environments.Should().BeEquivalentTo(["production", "staging"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasFactsAsync_ReturnsFalse_WhenSignalsReturnsNotFound()
|
||||
{
|
||||
var adapter = CreateAdapter(
|
||||
responseStatusCode: HttpStatusCode.NotFound,
|
||||
responseJson: string.Empty,
|
||||
new FakeTimeProvider(DateTimeOffset.UtcNow),
|
||||
out _);
|
||||
|
||||
var result = await adapter.HasFactsAsync("sha256:missing", "tenant-a", CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static SignalsHttpAdapter CreateAdapter(
|
||||
HttpStatusCode responseStatusCode,
|
||||
string responseJson,
|
||||
FakeTimeProvider timeProvider,
|
||||
out RecordingHandler handler)
|
||||
{
|
||||
handler = new RecordingHandler(responseStatusCode, responseJson);
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://signals.test", UriKind.Absolute)
|
||||
};
|
||||
|
||||
return new SignalsHttpAdapter(client, timeProvider, NullLogger<SignalsHttpAdapter>.Instance);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _responseJson;
|
||||
|
||||
public RecordingHandler(HttpStatusCode statusCode, string responseJson)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_responseJson = responseJson;
|
||||
}
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
var response = new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_responseJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
# StellaOps.ReachGraph.WebService.Tests Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260415_004_DOCS_runtime_data_plane_real_backend_cutover.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/StellaOps.ReachGraph.WebService.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| RUNTIME-002-T | DONE | Targeted proof for the Signals HTTP adapter and live host service resolution now passes in the full `StellaOps.ReachGraph.WebService.Tests` project (`30/30`). |
|
||||
|
||||
Reference in New Issue
Block a user