feat(secrets-grpc): FallbackEndpoints + sticky failover hub reader (scadaproj#4)
SecretsGrpcHubClientOptions gains a getter-only FallbackEndpoints list:
additional hub addresses tried in order when a call fails with a
transport-level RpcException. Validation fails startup on blank/relative
entries and on any duplicate of the primary or another fallback (a
duplicated endpoint silently halves the configured failover).
New FailoverSecretsHubReader composes one GrpcSecretsHubClient per
endpoint: the answering endpoint is sticky (a sweep is GetManifest + N
GetSecrets batches — without stickiness every call after a primary
outage re-pays a full CallDeadline before reaching the healthy
fallback), rotation wraps so a recovered primary is retried when the
stuck-to endpoint fails, only RpcException triggers failover
(cancellation and defects propagate untouched), and when every endpoint
fails the last RpcException is rethrown so the sweep degrades to
last-known-good exactly as today. Failover warnings name only the two
endpoint URIs and the status code — never any credential material.
DI: zero fallbacks keeps the registration graph byte-identical to 0.4.x
(single keyed channel under ChannelServiceKey, plain
GrpcSecretsHubClient); with fallbacks, one container-owned keyed channel
per endpoint (fallback i under "{ChannelServiceKey}:fallback:{i}") wired
into the failover reader. GrpcSecretsHubClient itself is unchanged.
Safe only when every listed endpoint serves the SAME store — for
ScadaBridge, the central pair over one shared SQL Server store; the
options doc and CHANGELOG carry the warning.
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
+60
@@ -75,6 +75,8 @@ public sealed class SecretsGrpcHubClientOptionsTests
|
||||
o => o.BearerToken = string.Empty,
|
||||
o => o.SyncInterval = TimeSpan.Zero,
|
||||
o => o.CallDeadline = TimeSpan.FromSeconds(-1),
|
||||
o => o.FallbackEndpoints.Add("not a uri"),
|
||||
o => o.FallbackEndpoints.Add("https://central:5001"),
|
||||
];
|
||||
|
||||
[Theory]
|
||||
@@ -116,6 +118,64 @@ public sealed class SecretsGrpcHubClientOptionsTests
|
||||
Assert.Contains("Secrets:GrpcHub:CallDeadline", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_fallback_endpoints_is_valid_and_the_default()
|
||||
{
|
||||
SecretsGrpcHubClientOptions options = Valid();
|
||||
|
||||
Assert.Empty(options.FallbackEndpoints);
|
||||
options.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_well_formed_fallback_endpoint_validates()
|
||||
{
|
||||
SecretsGrpcHubClientOptions options = Valid();
|
||||
options.FallbackEndpoints.Add("https://central-b:5001");
|
||||
options.FallbackEndpoints.Add("http://central-c:5001");
|
||||
|
||||
options.Validate();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("central-b:5001")]
|
||||
[InlineData("/relative/path")]
|
||||
[InlineData("ftp://central-b:5001")]
|
||||
public void A_fallback_that_is_not_an_absolute_http_uri_names_its_key(string fallback)
|
||||
{
|
||||
SecretsGrpcHubClientOptions options = Valid();
|
||||
options.FallbackEndpoints.Add(fallback);
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("Secrets:GrpcHub:FallbackEndpoints", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://central:5001")]
|
||||
[InlineData("HTTPS://CENTRAL:5001")]
|
||||
public void A_fallback_duplicating_the_primary_endpoint_names_its_key(string fallback)
|
||||
{
|
||||
// A duplicated endpoint silently halves the failover the operator thinks they configured.
|
||||
SecretsGrpcHubClientOptions options = Valid();
|
||||
options.FallbackEndpoints.Add(fallback);
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("Secrets:GrpcHub:FallbackEndpoints", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_fallback_duplicating_another_fallback_names_its_key()
|
||||
{
|
||||
SecretsGrpcHubClientOptions options = Valid();
|
||||
options.FallbackEndpoints.Add("https://central-b:5001");
|
||||
options.FallbackEndpoints.Add("HTTPS://central-B:5001");
|
||||
|
||||
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
Assert.Contains("Secrets:GrpcHub:FallbackEndpoints", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Messages_name_the_section_the_caller_actually_bound()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user