Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/SecretReplicationSerializer.cs
T
Joseph Doherty dd0a846b64 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
2026-07-18 04:08:23 -04:00

85 lines
3.8 KiB
C#

using System.Runtime.Serialization;
using System.Text.Json;
using Akka.Actor;
using Akka.Serialization;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet;
/// <summary>
/// Explicit System.Text.Json serializer for the secret-replication wire protocol.
/// </summary>
/// <remarks>
/// <para>
/// Registering this is <b>optional but recommended</b>. The protocol DTOs are all primitives, so
/// Akka's default JSON serializer round-trips them correctly if an application never wires this up —
/// but relying on the default means secret ciphertext is serialized by whatever the cluster's
/// fallback happens to be, which is not a decision worth leaving implicit for this payload. Binding
/// it explicitly also pins the manifest strings below as the versioning boundary: a future protocol
/// change adds a manifest, so a mixed-version cluster fails loudly on an unknown manifest instead of
/// silently mis-deserializing a secret.
/// </para>
/// <para>
/// Wire it up by merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the
/// application's Akka configuration — see that member for the HOCON.
/// </para>
/// </remarks>
/// <param name="system">The actor system this serializer belongs to.</param>
public sealed class SecretReplicationSerializer(ExtendedActorSystem system) : SerializerWithStringManifest(system)
{
/// <summary>Stable serializer id. Must not collide with other serializers in the cluster.</summary>
public const int SerializerId = 7710;
private const string RowsManifest = "zbs-rows-v1";
private const string ManifestAnnounceManifest = "zbs-manifest-v1";
private const string PullRequestManifest = "zbs-pull-v1";
private static readonly JsonSerializerOptions JsonOptions = new()
{
// Explicitly NOT indented and NOT camel-cased: the wire form should be compact and stable,
// not pretty. Property names are the C# names, pinned by the manifest version.
WriteIndented = false,
};
/// <inheritdoc />
public override int Identifier => SerializerId;
/// <inheritdoc />
public override string Manifest(object o) => o switch
{
SecretRowsMessage => RowsManifest,
SecretManifestAnnounce => ManifestAnnounceManifest,
SecretPullRequest => PullRequestManifest,
_ => throw new ArgumentException(
$"{nameof(SecretReplicationSerializer)} cannot serialize {o?.GetType().FullName ?? "null"}.",
nameof(o)),
};
/// <inheritdoc />
public override byte[] ToBinary(object obj) => obj switch
{
SecretRowsMessage rows => JsonSerializer.SerializeToUtf8Bytes(rows, JsonOptions),
SecretManifestAnnounce announce => JsonSerializer.SerializeToUtf8Bytes(announce, JsonOptions),
SecretPullRequest pull => JsonSerializer.SerializeToUtf8Bytes(pull, JsonOptions),
_ => throw new ArgumentException(
$"{nameof(SecretReplicationSerializer)} cannot serialize {obj?.GetType().FullName ?? "null"}.",
nameof(obj)),
};
/// <inheritdoc />
public override object FromBinary(byte[] bytes, string manifest) => manifest switch
{
RowsManifest => Deserialize<SecretRowsMessage>(bytes, manifest),
ManifestAnnounceManifest => Deserialize<SecretManifestAnnounce>(bytes, manifest),
PullRequestManifest => Deserialize<SecretPullRequest>(bytes, manifest),
_ => throw new SerializationException(
$"Unknown secret-replication manifest '{manifest}'. This usually means a peer is running a " +
"newer protocol version than this node."),
};
private static T Deserialize<T>(byte[] bytes, string manifest) =>
JsonSerializer.Deserialize<T>(bytes, JsonOptions)
?? throw new SerializationException(
$"Secret-replication payload with manifest '{manifest}' deserialized to null.");
}