feat(replay): truthful snapshot index runtime
Sprint SPRINT_20260416_004_Replay_truthful_snapshot_index_cutover. Replay WebService program wiring; runtime startup contract tests, point-in-time query API integration tests, test environment variable scope helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ builder.Services.AddSingleton<IAuditBundleReader, AuditBundleReader>();
|
|||||||
builder.Services.AddSingleton<IVerdictReplayPredicate, VerdictReplayPredicate>();
|
builder.Services.AddSingleton<IVerdictReplayPredicate, VerdictReplayPredicate>();
|
||||||
|
|
||||||
var replayStorageDriver = ResolveStorageDriver(builder.Configuration, "Replay");
|
var replayStorageDriver = ResolveStorageDriver(builder.Configuration, "Replay");
|
||||||
RegisterSnapshotIndexStore(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), replayStorageDriver);
|
RegisterSnapshotIndexStore(builder.Services, builder.Configuration, builder.Environment, replayStorageDriver);
|
||||||
RegisterSnapshotBlobStore(builder.Services, builder.Configuration, "Replay");
|
RegisterSnapshotBlobStore(builder.Services, builder.Configuration, "Replay");
|
||||||
builder.Services.AddSingleton(new FeedSnapshotServiceOptions());
|
builder.Services.AddSingleton(new FeedSnapshotServiceOptions());
|
||||||
builder.Services.AddSingleton<FeedSnapshotService>();
|
builder.Services.AddSingleton<FeedSnapshotService>();
|
||||||
@@ -436,22 +436,17 @@ await app.LoadTranslationsAsync();
|
|||||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||||
await app.RunAsync().ConfigureAwait(false);
|
await app.RunAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
static void RegisterSnapshotIndexStore(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver)
|
static void RegisterSnapshotIndexStore(IServiceCollection services, IConfiguration configuration, IHostEnvironment environment, string storageDriver)
|
||||||
{
|
{
|
||||||
if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var connectionString = ResolvePostgresConnectionString(configuration, "Replay");
|
var connectionString = ResolvePostgresConnectionString(configuration, "Replay");
|
||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
{
|
{
|
||||||
if (!isDevelopment)
|
throw new InvalidOperationException(
|
||||||
{
|
"Replay requires PostgreSQL connection settings when Storage:Driver=postgres. " +
|
||||||
throw new InvalidOperationException(
|
"Set ConnectionStrings:Default or Replay:Storage:Postgres:ConnectionString. " +
|
||||||
"Replay requires PostgreSQL connection settings in non-development mode. " +
|
"Live runtime no longer falls back to in-memory snapshot indexing; tests may opt into Replay:Storage:Driver=inmemory only in the Testing environment.");
|
||||||
"Set ConnectionStrings:Default or Replay:Storage:Postgres:ConnectionString.");
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddSingleton<IFeedSnapshotIndexStore>(_ => new PostgresFeedSnapshotIndexStore(connectionString));
|
services.AddSingleton<IFeedSnapshotIndexStore>(_ => new PostgresFeedSnapshotIndexStore(connectionString));
|
||||||
@@ -460,6 +455,13 @@ static void RegisterSnapshotIndexStore(IServiceCollection services, IConfigurati
|
|||||||
|
|
||||||
if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
if (!environment.IsEnvironment("Testing"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Replay storage driver 'inmemory' is supported only in the Testing environment. " +
|
||||||
|
"Use PostgreSQL-backed snapshot indexing for Development and live runtime.");
|
||||||
|
}
|
||||||
|
|
||||||
services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
|
services.AddSingleton<IFeedSnapshotIndexStore, InMemoryFeedSnapshotIndexStore>();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -492,8 +494,8 @@ static void RegisterSnapshotBlobStore(IServiceCollection services, IConfiguratio
|
|||||||
static string ResolveStorageDriver(IConfiguration configuration, string serviceName)
|
static string ResolveStorageDriver(IConfiguration configuration, string serviceName)
|
||||||
{
|
{
|
||||||
return FirstNonEmpty(
|
return FirstNonEmpty(
|
||||||
configuration["Storage:Driver"],
|
configuration[$"{serviceName}:Storage:Driver"],
|
||||||
configuration[$"{serviceName}:Storage:Driver"])
|
configuration["Storage:Driver"])
|
||||||
?? "postgres";
|
?? "postgres";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
|||||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||||
| SPRINT-312-006 | DONE | Added Postgres snapshot index + seed-fs snapshot blob stores and wired storage-driver registration in webservice startup. |
|
| SPRINT-312-006 | DONE | Added Postgres snapshot index + seed-fs snapshot blob stores and wired storage-driver registration in webservice startup. |
|
||||||
| SPRINT-20260305-003 | DONE | Replay storage contract closed: object-store narrowed to seed-fs only with deterministic rustfs/unknown-driver startup rejection and synced docs. |
|
| SPRINT-20260305-003 | DONE | Replay storage contract closed: object-store narrowed to seed-fs only with deterministic rustfs/unknown-driver startup rejection and synced docs. |
|
||||||
|
| REPLAY-REAL-001 | DONE | 2026-04-16: Removed the implicit Development fallback to `InMemoryFeedSnapshotIndexStore`; `Replay:Storage:Driver=inmemory` is now `Testing`-only and covered by startup-contract tests. |
|
||||||
|
|||||||
@@ -144,37 +144,13 @@ public sealed class PointInTimeQueryApiIntegrationTests
|
|||||||
|
|
||||||
private static WebApplicationFactory<Program> CreateFactory()
|
private static WebApplicationFactory<Program> CreateFactory()
|
||||||
{
|
{
|
||||||
return new WebApplicationFactory<Program>()
|
var environmentScope = new TestEnvironmentVariableScope(new Dictionary<string, string?>
|
||||||
.WithWebHostBuilder(builder =>
|
{
|
||||||
{
|
["REPLAY_Replay__Storage__Driver"] = "inmemory",
|
||||||
builder.UseEnvironment("Development");
|
["REPLAY_Storage__Driver"] = "inmemory"
|
||||||
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
});
|
||||||
{
|
|
||||||
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{
|
|
||||||
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
|
|
||||||
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
|
|
||||||
["Replay:Authority:RequireHttpsMetadata"] = "false",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
builder.ConfigureTestServices(services =>
|
|
||||||
{
|
|
||||||
services.AddAuthentication(TestReplayAuthHandler.SchemeName)
|
|
||||||
.AddScheme<AuthenticationSchemeOptions, TestReplayAuthHandler>(
|
|
||||||
TestReplayAuthHandler.SchemeName,
|
|
||||||
_ => { });
|
|
||||||
|
|
||||||
services.PostConfigureAll<AuthenticationOptions>(options =>
|
return new ScopedReplayWebApplicationFactory(environmentScope);
|
||||||
{
|
|
||||||
options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName;
|
|
||||||
options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName;
|
|
||||||
options.DefaultScheme = TestReplayAuthHandler.SchemeName;
|
|
||||||
});
|
|
||||||
|
|
||||||
services.RemoveAll<IAuthorizationHandler>();
|
|
||||||
services.AddSingleton<IAuthorizationHandler, AllowAllAuthorizationHandler>();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureClient(HttpClient client)
|
private static void ConfigureClient(HttpClient client)
|
||||||
@@ -220,4 +196,70 @@ public sealed class PointInTimeQueryApiIntegrationTests
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ScopedReplayWebApplicationFactory : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
private readonly IDisposable _environmentScope;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ScopedReplayWebApplicationFactory(IDisposable environmentScope)
|
||||||
|
{
|
||||||
|
_environmentScope = environmentScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
|
||||||
|
if (disposing && !_disposed)
|
||||||
|
{
|
||||||
|
_environmentScope.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await base.DisposeAsync();
|
||||||
|
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_environmentScope.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseEnvironment("Testing");
|
||||||
|
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||||
|
{
|
||||||
|
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
|
||||||
|
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
|
||||||
|
["Replay:Authority:RequireHttpsMetadata"] = "false"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.AddAuthentication(TestReplayAuthHandler.SchemeName)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, TestReplayAuthHandler>(
|
||||||
|
TestReplayAuthHandler.SchemeName,
|
||||||
|
_ => { });
|
||||||
|
|
||||||
|
services.PostConfigureAll<AuthenticationOptions>(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName;
|
||||||
|
options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName;
|
||||||
|
options.DefaultScheme = TestReplayAuthHandler.SchemeName;
|
||||||
|
});
|
||||||
|
|
||||||
|
services.RemoveAll<IAuthorizationHandler>();
|
||||||
|
services.AddSingleton<IAuthorizationHandler, AllowAllAuthorizationHandler>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using StellaOps.Replay.Core.FeedSnapshots;
|
||||||
|
using StellaOps.Replay.WebService;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Replay.Core.Tests.FeedSnapshots;
|
||||||
|
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Intent", "Operational")]
|
||||||
|
public sealed class ReplayRuntimeStartupContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DevelopmentHost_RejectsMissingPostgresSnapshotIndexConfiguration()
|
||||||
|
{
|
||||||
|
using var factory = CreateFactory("Development");
|
||||||
|
|
||||||
|
var act = () => factory.CreateClient();
|
||||||
|
|
||||||
|
act.Should()
|
||||||
|
.Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*Replay requires PostgreSQL connection settings when Storage:Driver=postgres*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DevelopmentHost_RejectsExplicitInMemorySnapshotIndexDriver()
|
||||||
|
{
|
||||||
|
using var environmentScope = CreateReplayEnvironmentScope("inmemory");
|
||||||
|
using var factory = CreateFactory(
|
||||||
|
"Development");
|
||||||
|
|
||||||
|
var act = () => factory.CreateClient();
|
||||||
|
|
||||||
|
act.Should()
|
||||||
|
.Throw<InvalidOperationException>()
|
||||||
|
.WithMessage("*Replay storage driver 'inmemory' is supported only in the Testing environment*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TestingHost_AllowsExplicitInMemorySnapshotIndexDriver()
|
||||||
|
{
|
||||||
|
using var environmentScope = CreateReplayEnvironmentScope("inmemory");
|
||||||
|
using var factory = CreateFactory(
|
||||||
|
"Testing");
|
||||||
|
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
var health = await client.GetAsync("/healthz");
|
||||||
|
|
||||||
|
health.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var store = factory.Services.GetRequiredService<IFeedSnapshotIndexStore>();
|
||||||
|
store.GetType().Name.Should().Be("InMemoryFeedSnapshotIndexStore");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WebApplicationFactory<Program> CreateFactory(
|
||||||
|
string environment,
|
||||||
|
IReadOnlyDictionary<string, string?>? overrides = null)
|
||||||
|
{
|
||||||
|
return new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.UseEnvironment(environment);
|
||||||
|
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Replay:Authority:Issuer"] = "https://authority.stella-ops.local",
|
||||||
|
["Replay:Authority:MetadataAddress"] = "https://authority.stella-ops.local/.well-known/openid-configuration",
|
||||||
|
["Replay:Authority:RequireHttpsMetadata"] = "false",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (overrides is not null)
|
||||||
|
{
|
||||||
|
foreach (var pair in overrides)
|
||||||
|
{
|
||||||
|
values[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configurationBuilder.AddInMemoryCollection(values);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TestEnvironmentVariableScope CreateReplayEnvironmentScope(string storageDriver)
|
||||||
|
{
|
||||||
|
return new TestEnvironmentVariableScope(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["REPLAY_Replay__Storage__Driver"] = storageDriver,
|
||||||
|
["REPLAY_Storage__Driver"] = storageDriver
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
|||||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||||
| SPRINT-312-006 | DONE | Added `ReplayFeedSnapshotStoresTests` and validated Postgres index + seed-fs blob stores via class-targeted xUnit execution (3/3 pass). |
|
| SPRINT-312-006 | DONE | Added `ReplayFeedSnapshotStoresTests` and validated Postgres index + seed-fs blob stores via class-targeted xUnit execution (3/3 pass). |
|
||||||
| SPRINT-20260305-003 | DONE | Added authenticated replay API integration test harness and revalidated Replay core suite (`dotnet test ...` passed 99/99). |
|
| SPRINT-20260305-003 | DONE | Added authenticated replay API integration test harness and revalidated Replay core suite (`dotnet test ...` passed 99/99). |
|
||||||
|
| REPLAY-REAL-001 | DONE | 2026-04-16: Added Replay startup-contract tests proving missing Postgres config and explicit `inmemory` driver both fail outside `Testing`, while `Testing` may opt in explicitly. |
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections;
|
||||||
|
|
||||||
|
namespace StellaOps.Replay.Core.Tests;
|
||||||
|
|
||||||
|
internal sealed class TestEnvironmentVariableScope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public TestEnvironmentVariableScope(IReadOnlyDictionary<string, string?> variables)
|
||||||
|
{
|
||||||
|
foreach (var key in variables.Keys)
|
||||||
|
{
|
||||||
|
_originalValues[key] = Environment.GetEnvironmentVariable(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in variables)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in _originalValues)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user