feat(secrets): cluster replication via SQL Server and Akka.NET (G-7, 0.2.0)

Secrets were per-node SQLite, so a secret written on one node was invisible to
the rest of a cluster. G-7's design resolved the "shared SQL store vs Akka
replicator" fork to build only the former; both are built here so the choice is
a deployment decision (availability vs partition tolerance) rather than a
library limitation.

Two new packages — ZB.MOM.WW.Secrets.Replicator.SqlServer (shared store, plus a
local-store-with-hub mode) and .Replicator.AkkaDotNet (peer-to-peer over
distributed pub/sub). Core gains ISecretsStoreMigrator, one shared
SecretLastWriterWins predicate so no two stores can disagree on a tie, the
transport-agnostic reconciler, and ReplicatingSecretStore — which closes a real
gap: nothing had ever called ISecretReplicator.PublishAsync, so the seam was
inert and local writes would not have propagated at all.

Verified 182 pass / 1 skip / 0 warnings, including 15 live tests against a real
SQL Server 2022 (the SQLite suite ported case-for-case, so any behavioural
divergence between the stores fails) and a 9-test in-process 2-node Akka
cluster over real remoting. A post-build review caught six defects, all fixed
and now covered: both replication modes could not resolve from the container
(no test had built one), an unbounded fetch that broke past SQL Server's
2100-parameter cap, a poison row that aborted the rest of its batch forever,
Enum.Parse on peer input that could restart the actor in a loop, null crypto
blobs crossing the trust boundary, and a silently dropped pull-read failure.

Packed at 0.2.0 and vulnerability-scanned clean; not yet published to the feed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
Joseph Doherty
2026-07-18 04:08:23 -04:00
parent e46060fada
commit dd0a846b64
55 changed files with 4848 additions and 51 deletions
@@ -0,0 +1,68 @@
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests.Live;
/// <summary>
/// Shared connection details for the env-gated live SQL-Server suite.
/// </summary>
/// <remarks>
/// Gated on <c>SECRETS_SQLSERVER_CONNSTR</c> and skips cleanly when it is unset, matching the family
/// live-test idiom — the offline suite must stay runnable on any machine. Set it to a database the
/// test is allowed to create and drop a schema in, for example:
/// <c>Server=10.100.0.35,31433;Database=ZbSecretsTest;User Id=sa;Password=...;TrustServerCertificate=True</c>.
/// </remarks>
public static class LiveSqlServer
{
/// <summary>Environment variable holding the live connection string.</summary>
public const string ConnectionStringVariable = "SECRETS_SQLSERVER_CONNSTR";
/// <summary>The configured connection string, or <see langword="null"/> when the suite is not enabled.</summary>
public static string? ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariable);
/// <summary>Whether the live suite should run.</summary>
public static bool IsEnabled => !string.IsNullOrWhiteSpace(ConnectionString);
/// <summary>Skip reason shown when the suite is not enabled.</summary>
public const string SkipReason =
"Live SQL-Server suite disabled; set SECRETS_SQLSERVER_CONNSTR to enable.";
/// <summary>
/// Builds options against a throwaway schema so parallel runs and repeat runs cannot collide,
/// and so a failed run leaves no trace in a shared database.
/// </summary>
/// <param name="schemaName">The unique schema name for this test.</param>
/// <returns>Options targeting that schema.</returns>
public static SqlServerSecretsOptions OptionsFor(string schemaName) => new()
{
ConnectionString = ConnectionString!,
SchemaName = schemaName,
};
/// <summary>Generates a unique, allow-list-legal schema name for one test class.</summary>
/// <returns>A fresh schema name.</returns>
public static string NewSchemaName() => $"zbtest_{Guid.NewGuid():N}"[..32];
/// <summary>Drops the throwaway schema and its tables.</summary>
/// <param name="schemaName">The schema to drop.</param>
/// <returns>A task that completes when the schema is gone.</returns>
public static async Task DropSchemaAsync(string schemaName)
{
if (!IsEnabled)
{
return;
}
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
await using SqlCommand command = connection.CreateCommand();
command.CommandText = $"""
IF OBJECT_ID(N'[{schemaName}].[secret]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[secret];
IF OBJECT_ID(N'[{schemaName}].[schema_version]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[schema_version];
IF EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}') EXEC(N'DROP SCHEMA [{schemaName}]');
""";
await command.ExecuteNonQueryAsync();
}
}