feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// LocalDb Phase 1 (Task 5) — the dedicated h2c sync listener and, more importantly, the
|
||||
/// re-binding of the endpoints the host was already serving.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this file exists.</b> The host binds exclusively via <c>ASPNETCORE_URLS</c> and has
|
||||
/// no <c>ConfigureKestrel</c> call. Any explicit <c>Listen*</c> makes Kestrel discard that
|
||||
/// configuration wholesale — it logs "Overriding address(es)" and serves only what was
|
||||
/// listed explicitly. Getting this wrong unbinds the AdminUI and the deploy API behind
|
||||
/// Traefik, and it fails silently: the process starts, logs look normal, and nothing answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These tests drive a minimal <c>WebApplication</c> shaped exactly like <c>Program.cs</c>'s
|
||||
/// block rather than booting the real host, which needs SQL Server, an Akka mesh and LDAP.
|
||||
/// What is under test is the Kestrel binding contract, and that is fully reproduced here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbSyncListenerTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// KestrelHttpBinding.Parse
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Parse_NullOrEmpty_YieldsNoBindings()
|
||||
{
|
||||
// "Nothing configured" is a real supported state: Install-Services.ps1 sets ASPNETCORE_URLS
|
||||
// only for admin nodes, so a driver-only service genuinely has none.
|
||||
KestrelHttpBinding.Parse(null).ShouldBeEmpty();
|
||||
KestrelHttpBinding.Parse("").ShouldBeEmpty();
|
||||
KestrelHttpBinding.Parse(" ").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://+:9000", "+", 9000)]
|
||||
[InlineData("http://*:9000", "*", 9000)]
|
||||
[InlineData("http://localhost:9000", "localhost", 9000)]
|
||||
[InlineData("http://0.0.0.0:8080", "0.0.0.0", 8080)]
|
||||
[InlineData("http://127.0.0.1:5001", "127.0.0.1", 5001)]
|
||||
public void Parse_SingleUrl_ExtractsHostAndPort(string url, string expectedHost, int expectedPort)
|
||||
{
|
||||
// The "+" and "*" wildcards are Kestrel-isms that System.Uri cannot parse — the docker-dev
|
||||
// rig uses "http://+:9000", so mishandling them would break every rig node.
|
||||
var bindings = KestrelHttpBinding.Parse(url);
|
||||
|
||||
bindings.Count.ShouldBe(1);
|
||||
bindings[0].Host.ShouldBe(expectedHost);
|
||||
bindings[0].Port.ShouldBe(expectedPort);
|
||||
bindings[0].IsSecure.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MultipleUrls_PreservesAllOfThem()
|
||||
{
|
||||
// Dropping one of several configured endpoints is the exact silent-unbind failure this
|
||||
// whole mechanism exists to avoid.
|
||||
var bindings = KestrelHttpBinding.Parse("http://+:9000;http://localhost:9100");
|
||||
|
||||
bindings.Count.ShouldBe(2);
|
||||
bindings[0].Port.ShouldBe(9000);
|
||||
bindings[1].Port.ShouldBe(9100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HttpsUrl_IsFlaggedSecure()
|
||||
{
|
||||
// Program.cs refuses to take over Kestrel when any endpoint is HTTPS, because replaying
|
||||
// certificate configuration is not modelled here. This flag is what drives that refusal.
|
||||
KestrelHttpBinding.Parse("https://+:443")[0].IsSecure.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MalformedEntry_IsSkipped_NotThrown()
|
||||
{
|
||||
// A typo'd URL must not take the process down at startup.
|
||||
KestrelHttpBinding.Parse("not a url;http://+:9000").ShouldHaveSingleItem().Port.ShouldBe(9000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The real Kestrel contract
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitListen_ReBindsTheExistingSurface_AndAddsAnH2cListener()
|
||||
{
|
||||
// THE test. Both ports must answer simultaneously: the re-bound HTTP/1.1 surface (proving
|
||||
// the ASPNETCORE_URLS override was compensated for) and the HTTP/2-only sync port (proving
|
||||
// prior-knowledge h2c works, which a cleartext Http1AndHttp2 endpoint cannot do).
|
||||
var httpPort = GetFreePort();
|
||||
var syncPort = GetFreePort();
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Environment.ApplicationName = typeof(LocalDbSyncListenerTests).Assembly.GetName().Name!;
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
// Exactly the shape Program.cs applies.
|
||||
foreach (var binding in KestrelHttpBinding.Parse($"http://localhost:{httpPort}"))
|
||||
{
|
||||
var captured = binding;
|
||||
builder.WebHost.ConfigureKestrel(k => captured.Apply(k));
|
||||
}
|
||||
|
||||
builder.WebHost.ConfigureKestrel(k =>
|
||||
k.ListenAnyIP(syncPort, o => o.Protocols = HttpProtocols.Http2));
|
||||
|
||||
await using var app = builder.Build();
|
||||
app.MapGet("/healthz", () => "ok");
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
using var http1 = new HttpClient();
|
||||
var body = await http1.GetStringAsync(
|
||||
$"http://localhost:{httpPort}/healthz", TestContext.Current.CancellationToken);
|
||||
body.ShouldBe("ok");
|
||||
|
||||
// Prior-knowledge h2c against the sync port. A 404 is a perfectly good result — it
|
||||
// proves the HTTP/2 connection was established and the request was routed, which is
|
||||
// the only thing in question here.
|
||||
using var http2 = new HttpClient
|
||||
{
|
||||
DefaultRequestVersion = HttpVersion.Version20,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
|
||||
};
|
||||
using var response = await http2.GetAsync(
|
||||
$"http://localhost:{syncPort}/", TestContext.Current.CancellationToken);
|
||||
|
||||
response.Version.ShouldBe(HttpVersion.Version20);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitListen_WithoutReBinding_SilentlyDiscardsTheConfiguredUrls()
|
||||
{
|
||||
// POSITIVE CONTROL for the whole task. If Kestrel did NOT override configured URLs, the
|
||||
// re-binding above would be pointless ceremony. This pins the actual behaviour: a single
|
||||
// explicit Listen* call makes the ASPNETCORE_URLS port stop answering entirely — no
|
||||
// exception, no failed startup, just silence. That is the regression being guarded against.
|
||||
var configuredPort = GetFreePort();
|
||||
var explicitPort = GetFreePort();
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Environment.ApplicationName = typeof(LocalDbSyncListenerTests).Assembly.GetName().Name!;
|
||||
builder.WebHost.UseUrls($"http://localhost:{configuredPort}");
|
||||
|
||||
// Deliberately NOT re-binding configuredPort — this is the mistake being demonstrated.
|
||||
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(explicitPort));
|
||||
|
||||
await using var app = builder.Build();
|
||||
app.MapGet("/healthz", () => "ok");
|
||||
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||
|
||||
// The explicitly listed port serves.
|
||||
(await client.GetStringAsync(
|
||||
$"http://localhost:{explicitPort}/healthz",
|
||||
TestContext.Current.CancellationToken)).ShouldBe("ok");
|
||||
|
||||
// The configured one does not — nothing is listening there at all.
|
||||
await Should.ThrowAsync<HttpRequestException>(() => client.GetStringAsync(
|
||||
$"http://localhost:{configuredPort}/healthz",
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Deployment;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// LocalDb Phase 1 (Task 6) — the chunked deployment-artifact cache.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Driven against a real temp-file <see cref="ILocalDb"/> rather than a fake. The behaviours
|
||||
/// under test — chunk reassembly order, SHA-256 integrity, retention pruning, upsert
|
||||
/// semantics — live entirely in SQL, so a mocked seam would assert nothing about them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This project cannot reference the Host, so the schema setup here mirrors
|
||||
/// <c>LocalDbSetup.OnReady</c> rather than calling it. The load-bearing
|
||||
/// <c>DDL → RegisterReplicated</c> ordering is pinned by the Host's own
|
||||
/// <c>LocalDbSetupTests</c>; what matters here is only that the tables exist.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbDeploymentArtifactCacheTests : IDisposable
|
||||
{
|
||||
private const string ClusterA = "SITE-A";
|
||||
private const string ClusterB = "SITE-B";
|
||||
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), $"otopcua-artifact-cache-{Guid.NewGuid():N}.db");
|
||||
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly ILocalDb _db;
|
||||
|
||||
public LocalDbDeploymentArtifactCacheTests()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
||||
.Build();
|
||||
|
||||
_provider = new ServiceCollection()
|
||||
.AddZbLocalDb(configuration, db =>
|
||||
{
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
DeploymentCacheSchema.Apply(connection);
|
||||
}
|
||||
|
||||
db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable);
|
||||
db.RegisterReplicated(DeploymentCacheSchema.PointerTable);
|
||||
})
|
||||
.BuildServiceProvider();
|
||||
|
||||
_db = _provider.GetRequiredService<ILocalDb>();
|
||||
}
|
||||
|
||||
private LocalDbDeploymentArtifactCache CreateCache(
|
||||
ILogger<LocalDbDeploymentArtifactCache>? logger = null)
|
||||
=> new(_db, logger ?? NullLogger<LocalDbDeploymentArtifactCache>.Instance);
|
||||
|
||||
private static byte[] RandomArtifact(int length)
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
Random.Shared.NextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task<int> CountChunksAsync(string? deploymentId = null)
|
||||
{
|
||||
var sql = deploymentId is null
|
||||
? "SELECT COUNT(*) FROM deployment_artifacts"
|
||||
: "SELECT COUNT(*) FROM deployment_artifacts WHERE deployment_id = @DeploymentId";
|
||||
|
||||
var rows = await _db.QueryAsync(sql, r => r.GetInt32(0),
|
||||
deploymentId is null ? null : new { DeploymentId = deploymentId });
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> DistinctDeploymentIdsAsync()
|
||||
=> await _db.QueryAsync(
|
||||
"SELECT DISTINCT deployment_id FROM deployment_artifacts ORDER BY deployment_id",
|
||||
r => r.GetString(0));
|
||||
|
||||
[Fact]
|
||||
public async Task StoreThenGetCurrent_RoundTripsAMultiChunkArtifactByteForByte()
|
||||
{
|
||||
// 300 KiB against a 128 KiB chunk forces three chunks, so this covers the reassembly
|
||||
// ordering that a single-chunk artifact would never exercise.
|
||||
var artifact = RandomArtifact(300 * 1024);
|
||||
var cache = CreateCache();
|
||||
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
||||
|
||||
var cached = await cache.GetCurrentAsync(ClusterA);
|
||||
|
||||
cached.ShouldNotBeNull();
|
||||
cached.DeploymentId.ShouldBe("dep-1");
|
||||
cached.RevisionHash.ShouldBe("rev-1");
|
||||
cached.Artifact.ShouldBe(artifact);
|
||||
(await CountChunksAsync("dep-1")).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_KeepsOnlyTheTwoNewestDeploymentsPerCluster()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(64));
|
||||
await cache.StoreAsync(ClusterA, "dep-2", "rev-2", RandomArtifact(64));
|
||||
await cache.StoreAsync(ClusterA, "dep-3", "rev-3", RandomArtifact(64));
|
||||
|
||||
// Bounded retention is what stops a node that redeploys daily from filling its disk with
|
||||
// address spaces nobody will ever roll back to.
|
||||
(await DistinctDeploymentIdsAsync()).ShouldBe(["dep-2", "dep-3"]);
|
||||
|
||||
var cached = await cache.GetCurrentAsync(ClusterA);
|
||||
cached.ShouldNotBeNull();
|
||||
cached.DeploymentId.ShouldBe("dep-3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrent_ReturnsNullWhenAChunkIsCorrupt()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));
|
||||
|
||||
// Valid base64 of the wrong bytes: this passes decoding and the chunk-count check, so only
|
||||
// the SHA-256 comparison can catch it. That is the check being pinned.
|
||||
await _db.ExecuteAsync(
|
||||
"UPDATE deployment_artifacts SET chunk_base64 = @Chunk WHERE deployment_id = @DeploymentId AND chunk_index = 1",
|
||||
new { Chunk = Convert.ToBase64String(RandomArtifact(128 * 1024)), DeploymentId = "dep-1" });
|
||||
|
||||
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrent_ReturnsNullWhenAChunkIsMissing()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));
|
||||
|
||||
// A partially replicated artifact is the realistic version of this: the pointer row arrives
|
||||
// before the last chunk does. Reassembling what is present would yield a plausible-looking
|
||||
// but silently truncated address space.
|
||||
await _db.ExecuteAsync(
|
||||
"DELETE FROM deployment_artifacts WHERE deployment_id = @DeploymentId AND chunk_index = 2",
|
||||
new { DeploymentId = "dep-1" });
|
||||
|
||||
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrent_ReturnsNullWhenNoPointerExists()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
|
||||
(await cache.GetCurrentAsync(ClusterA)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_IsIdempotentForTheSameDeploymentId()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", RandomArtifact(300 * 1024));
|
||||
|
||||
// The re-store is smaller, so a delete-before-insert that did not happen would leave the
|
||||
// tail chunks of the first artifact behind — orphans that also break the chunk-count check.
|
||||
var second = RandomArtifact(200 * 1024);
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1b", second);
|
||||
|
||||
(await CountChunksAsync("dep-1")).ShouldBe(2);
|
||||
(await CountChunksAsync()).ShouldBe(2);
|
||||
|
||||
var cached = await cache.GetCurrentAsync(ClusterA);
|
||||
cached.ShouldNotBeNull();
|
||||
cached.RevisionHash.ShouldBe("rev-1b");
|
||||
cached.Artifact.ShouldBe(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUnkeyed_ReturnsTheOnlyCachedArtifact()
|
||||
{
|
||||
var artifact = RandomArtifact(300 * 1024);
|
||||
var cache = CreateCache();
|
||||
|
||||
await cache.StoreAsync(ClusterA, "dep-1", "rev-1", artifact);
|
||||
|
||||
var cached = await cache.GetCurrentUnkeyedAsync();
|
||||
|
||||
cached.ShouldNotBeNull();
|
||||
cached.DeploymentId.ShouldBe("dep-1");
|
||||
cached.Artifact.ShouldBe(artifact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUnkeyed_TakesTheNewestPointerAndWarnsNamingBothClusters()
|
||||
{
|
||||
var newest = RandomArtifact(1024);
|
||||
var logger = new CapturingLogger();
|
||||
var cache = CreateCache(logger);
|
||||
|
||||
await cache.StoreAsync(ClusterA, "dep-a", "rev-a", RandomArtifact(1024));
|
||||
await cache.StoreAsync(ClusterB, "dep-b", "rev-b", newest);
|
||||
|
||||
var cached = await cache.GetCurrentUnkeyedAsync();
|
||||
|
||||
cached.ShouldNotBeNull();
|
||||
cached.DeploymentId.ShouldBe("dep-b");
|
||||
cached.Artifact.ShouldBe(newest);
|
||||
|
||||
// A re-homed node booting a neighbouring cluster's configuration must be operator-visible.
|
||||
// Naming both clusters is what turns "wrong tags appeared" into a one-line diagnosis.
|
||||
var warning = logger.Entries.ShouldHaveSingleItem();
|
||||
warning.Level.ShouldBe(LogLevel.Warning);
|
||||
warning.Message.ShouldContain(ClusterA);
|
||||
warning.Message.ShouldContain(ClusterB);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
|
||||
// Real files, and pooled connections outlive the provider — clearing the pools is what
|
||||
// makes the delete actually succeed.
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal logger that records formatted messages so a test can assert on them.</summary>
|
||||
private sealed class CapturingLogger : ILogger<LocalDbDeploymentArtifactCache>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user