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:
@@ -2,7 +2,8 @@ namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an encrypted row to cluster peers. Core registers a no-op implementation;
|
||||
/// <c>ZB.MOM.WW.Secrets.Akka</c> (deferred) provides the real cluster implementation.
|
||||
/// <c>ZB.MOM.WW.Secrets.Replication.Akka</c> (peer-to-peer over the cluster) and
|
||||
/// <c>ZB.MOM.WW.Secrets.Replication.SqlServer</c> (via a shared hub database) provide the real ones.
|
||||
/// </summary>
|
||||
public interface ISecretReplicator
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions the schema backing an <see cref="ISecretStore"/>. Implementations are idempotent —
|
||||
/// running a migration repeatedly is safe — and refuse to run against a store whose on-disk schema
|
||||
/// version is <em>newer</em> than the build supports, rather than silently downgrading it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This seam exists so the startup migration hosted service and the CLI stay store-agnostic: the
|
||||
/// SQLite store, the shared SQL-Server store, and any future provider are all provisioned through
|
||||
/// the same call, and swapping the store never touches the migration wiring.
|
||||
/// </remarks>
|
||||
public interface ISecretsStoreMigrator
|
||||
{
|
||||
/// <summary>Creates or updates the secret-store schema to the version this build supports.</summary>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns>A task that completes when the schema is at the supported version.</returns>
|
||||
/// <exception cref="SecretStoreMigrationException">
|
||||
/// The store's schema version is newer than this build supports.
|
||||
/// </exception>
|
||||
Task MigrateAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// The single definition of the last-writer-wins (LWW) ordering used to reconcile a secret row
|
||||
/// against a peer's copy: newer <see cref="StoredSecret.UpdatedUtc"/> wins, and
|
||||
/// <see cref="StoredSecret.Revision"/> breaks a timestamp tie.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every store's <see cref="ISecretStore.ApplyReplicatedAsync"/> and every replication transport
|
||||
/// reconciler routes its "is the incoming row newer?" decision through here. Keeping one definition
|
||||
/// is a correctness requirement, not a tidiness one: a cluster can mix stores (a node backed by the
|
||||
/// shared SQL-Server store alongside nodes on local SQLite), and two implementations that disagree
|
||||
/// about a tie would converge to <em>different</em> rows on different nodes — a silent split-brain
|
||||
/// that no single-node test can catch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The comparison is deliberately <b>strict</b>: a row that merely ties the local one is not newer
|
||||
/// and is ignored. That makes replication idempotent — re-delivering a row a node already has is a
|
||||
/// no-op rather than a write — so at-least-once transports (pub/sub redelivery, an anti-entropy
|
||||
/// sweep racing a live broadcast) cost nothing and cannot flap a row back and forth.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SecretLastWriterWins
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if a row at (<paramref name="incomingUpdatedUtc"/>,
|
||||
/// <paramref name="incomingRevision"/>) should overwrite a local row at
|
||||
/// (<paramref name="localUpdatedUtc"/>, <paramref name="localRevision"/>).
|
||||
/// </summary>
|
||||
/// <param name="incomingUpdatedUtc">The incoming row's last-updated timestamp.</param>
|
||||
/// <param name="incomingRevision">The incoming row's revision.</param>
|
||||
/// <param name="localUpdatedUtc">The local row's last-updated timestamp.</param>
|
||||
/// <param name="localRevision">The local row's revision.</param>
|
||||
/// <returns><see langword="true"/> if the incoming row is strictly newer.</returns>
|
||||
public static bool IsNewer(
|
||||
DateTimeOffset incomingUpdatedUtc,
|
||||
long incomingRevision,
|
||||
DateTimeOffset localUpdatedUtc,
|
||||
long localRevision) =>
|
||||
incomingUpdatedUtc > localUpdatedUtc ||
|
||||
(incomingUpdatedUtc == localUpdatedUtc && incomingRevision > localRevision);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if <paramref name="incoming"/> should overwrite the local row
|
||||
/// described by <paramref name="local"/>.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The manifest entry received from a peer.</param>
|
||||
/// <param name="local">The corresponding local manifest entry.</param>
|
||||
/// <returns><see langword="true"/> if the incoming entry is strictly newer.</returns>
|
||||
public static bool IsNewer(SecretManifestEntry incoming, SecretManifestEntry local)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
|
||||
return IsNewer(incoming.UpdatedUtc, incoming.Revision, local.UpdatedUtc, local.Revision);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user