c3b07d6766
The SQL-Server extensions called the 3-arg AddZbSecrets, and the store-path validator captured the content root of whichever call registered it first. In hub mode no registration order applied the pre-host under-content-root guard: extension-first dropped the app's 4-arg root, core-first lost the replicator (#30). That is the 0.6.0 store-path hole, reopened. - AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication / AddZbSecretsAkkaReplication gain a trailing optional contentRootPath, forwarded to the 4-arg AddZbSecrets - the root now lives in one SecretsContentRoot instance holder per service collection; the last explicit root wins, null never erases one; the IHostEnvironment fallback is unchanged - tests: ContentRootTests (hub mode, three compositions, artifact asserted before the throw, positive control), last-explicit-wins theory in SecretsStorePathRulesTests, Akka forwarding test - code-reviews: Secrets-002 Resolved; README regenerated Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
147 lines
8.1 KiB
C#
147 lines
8.1 KiB
C#
using Akka.Actor;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Replication;
|
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
|
using ZB.MOM.WW.Secrets.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
|
|
|
/// <summary>
|
|
/// Dependency-injection entry point for peer-to-peer secret replication over an Akka.NET cluster.
|
|
/// </summary>
|
|
public static class AkkaSecretsServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// Registers secret replication over the cluster: each node keeps its own local store, local
|
|
/// writes broadcast to peers, and a periodic manifest exchange repairs any divergence in both
|
|
/// directions.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Choose this transport when a node must keep resolving secrets while <b>partitioned</b> from
|
|
/// the rest of the cluster — reads never leave the node, so an isolated site keeps running on its
|
|
/// last-known-good state and re-converges when the partition heals. If every node can always
|
|
/// reach a shared database, the SQL-Server package's shared-store mode is simpler and has no
|
|
/// distributed failure modes at all; prefer it unless partition tolerance is a stated requirement.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Requirements.</b> The application must (1) register its <see cref="ActorSystem"/> in DI
|
|
/// before the first secret resolve, and (2) give every node the <b>same KEK</b> — rows carry
|
|
/// ciphertext only, so a node with a different master key fails closed on a <c>kek_id</c>
|
|
/// mismatch. Merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the actor
|
|
/// system's configuration is recommended.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>KEK rotation does not replicate.</b> A re-wrap deliberately leaves the revision untouched
|
|
/// so it stays invisible to last-writer-wins; run <c>rewrap-all</c> against every node's local
|
|
/// store.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="services">The service collection to add to.</param>
|
|
/// <param name="config">The application configuration.</param>
|
|
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
|
|
/// <param name="replicationSectionPath">Configuration section holding the replication options.</param>
|
|
/// <param name="contentRootPath">
|
|
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
|
|
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
|
|
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
|
|
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
|
|
/// </param>
|
|
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
|
public static IServiceCollection AddZbSecretsAkkaReplication(
|
|
this IServiceCollection services,
|
|
IConfiguration config,
|
|
string secretsSectionPath = "Secrets",
|
|
string replicationSectionPath = "Secrets:Replication",
|
|
string? contentRootPath = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(secretsSectionPath);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(replicationSectionPath);
|
|
|
|
IConfigurationSection section = config.GetSection(replicationSectionPath);
|
|
services.Configure<AkkaSecretsReplicationOptions>(section);
|
|
|
|
// Validate eagerly so a bad interval fails at startup, not on the first announce tick.
|
|
var eager = new AkkaSecretsReplicationOptions();
|
|
section.Bind(eager);
|
|
eager.Validate();
|
|
|
|
// The actor is created lazily on first use rather than at registration: the ActorSystem is
|
|
// typically registered by the application AFTER this call, and a cluster node may also not be
|
|
// ready to join at the moment the container is built.
|
|
//
|
|
// The invalidator MUST stay deferred (scadaproj#1): it is the resolver, the resolver reads
|
|
// ISecretStore, and ISecretStore is the ReplicatingSecretStore decorator registered below —
|
|
// resolving it here closes a singleton cycle the container cannot detect through factory
|
|
// lambdas, and every hosted process then deadlocks at startup inside the container's
|
|
// resolution locks. DeferredSecretCacheInvalidator waits until the first eviction, which
|
|
// can only happen after the graph has finished resolving.
|
|
services.TryAddSingleton<SecretReplicationActorProvider>(sp => new SecretReplicationActorProvider(
|
|
sp.GetRequiredService<ActorSystem>(),
|
|
sp.GetRequiredService<SqliteSecretStore>(),
|
|
new DeferredSecretCacheInvalidator(sp.GetService<ISecretCacheInvalidator>),
|
|
sp.GetRequiredService<IOptions<AkkaSecretsReplicationOptions>>().Value));
|
|
|
|
// The local store + migrator come from AddZbSecrets (SQLite by default).
|
|
services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
|
|
|
|
// REPLACED after the core call rather than TryAdd-ed before it (scadaproj#30). AddZbSecrets
|
|
// does TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>(); a TryAdd here only won when
|
|
// this package ran first, so an application that had already called AddZbSecrets got
|
|
// replication bound to the no-op sink — publishing nothing, no actor ever created, no error
|
|
// (the 0.2.0 shape, and still reachable from the application's side until 0.6.6).
|
|
services.RemoveAll<ISecretReplicator>();
|
|
services.AddSingleton<ISecretReplicator>(sp =>
|
|
new AkkaSecretReplicator(sp.GetRequiredService<SecretReplicationActorProvider>().ActorRef));
|
|
|
|
// Decorate the local store so every write publishes. Resolved from the concrete SQLite store,
|
|
// not ISecretStore — asking the container for ISecretStore here would resolve this very
|
|
// decorator and recurse forever. The core's ISecretStore forward is removed first so the
|
|
// decorator is the only registration in either call order.
|
|
services.RemoveAll<ISecretStore>();
|
|
services.AddSingleton<ISecretStore>(sp => new ReplicatingSecretStore(
|
|
sp.GetRequiredService<SqliteSecretStore>(),
|
|
sp.GetRequiredService<ISecretReplicator>(),
|
|
sp.GetRequiredService<ILogger<ReplicatingSecretStore>>()));
|
|
|
|
return services;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates this node's <see cref="SecretReplicationActor"/> on first access and hands out its ref.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The indirection exists because the actor must be created lazily — the application usually
|
|
/// registers its <see cref="ActorSystem"/> after <c>AddZbSecretsAkkaReplication</c>, so eagerly
|
|
/// resolving one at registration time would fail. Creation is guarded by
|
|
/// <see cref="Lazy{T}"/> with <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> so
|
|
/// concurrent first writes cannot spawn two actors on the same node.
|
|
/// </remarks>
|
|
/// <param name="system">The application's actor system.</param>
|
|
/// <param name="store">The node's local secret store — the undecorated one.</param>
|
|
/// <param name="cacheInvalidator">Resolver-cache seam.</param>
|
|
/// <param name="options">Replication options.</param>
|
|
public sealed class SecretReplicationActorProvider(
|
|
ActorSystem system,
|
|
ISecretStore store,
|
|
ISecretCacheInvalidator? cacheInvalidator,
|
|
AkkaSecretsReplicationOptions options)
|
|
{
|
|
private readonly Lazy<IActorRef> _actorRef = new(
|
|
() => system.ActorOf(
|
|
SecretReplicationActor.Props(store, cacheInvalidator, options.AnnounceInterval),
|
|
options.ActorName),
|
|
LazyThreadSafetyMode.ExecutionAndPublication);
|
|
|
|
/// <summary>This node's replication actor, created on first access.</summary>
|
|
public IActorRef ActorRef => _actorRef.Value;
|
|
}
|