feat(mesh): Grpc mode value + validation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
@@ -10,10 +11,35 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
||||
/// trace. Absences are the hardest class of fault to attribute in the field, so the whole point
|
||||
/// of these tests is that the host refuses to start instead.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Role-dependent facts run in <see cref="RoleEnvironmentCollection"/> because the validator
|
||||
/// resolves roles through <c>RoleParser.ResolveEffectiveRoles</c>, which reads
|
||||
/// <c>OTOPCUA_ROLES</c> when <c>Cluster:Roles</c> is empty — a process-wide env var, so the
|
||||
/// whole class is serialized against the other role-reading suites.
|
||||
/// </remarks>
|
||||
[Collection(RoleEnvironmentCollection.Name)]
|
||||
public class MeshTransportOptionsValidatorTests
|
||||
{
|
||||
private static ValidateOptionsResult Validate(MeshTransportOptions o) =>
|
||||
new MeshTransportOptionsValidator().Validate(MeshTransportOptions.SectionName, o);
|
||||
private static ValidateOptionsResult Validate(MeshTransportOptions o, params string[] roles)
|
||||
{
|
||||
var pairs = new Dictionary<string, string?>();
|
||||
for (var i = 0; i < roles.Length; i++)
|
||||
{
|
||||
pairs[$"Cluster:Roles:{i}"] = roles[i];
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build();
|
||||
|
||||
return new MeshTransportOptionsValidator(configuration)
|
||||
.Validate(MeshTransportOptions.SectionName, o);
|
||||
}
|
||||
|
||||
private static MeshTransportOptions WellFormedGrpc() => new()
|
||||
{
|
||||
Mode = MeshTransportOptions.ModeGrpc,
|
||||
GrpcPsk = "not-a-real-key",
|
||||
CentralGrpcEndpoints = ["http://central-1:4055", "https://central-2:4055"],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Default_options_are_valid()
|
||||
@@ -26,10 +52,23 @@ public class MeshTransportOptionsValidatorTests
|
||||
[Fact]
|
||||
public void Unknown_mode_fails()
|
||||
{
|
||||
var result = Validate(new MeshTransportOptions { Mode = "grpc" });
|
||||
var result = Validate(new MeshTransportOptions { Mode = "receptionist" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("grpc");
|
||||
result.FailureMessage.ShouldContain("receptionist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_mode_message_names_all_three_accepted_values()
|
||||
{
|
||||
// An operator who mistypes the mode gets the full menu, not a partial one — Grpc became a
|
||||
// legal value in Phase 0 and an error that omitted it would send them back to ClusterClient.
|
||||
var result = Validate(new MeshTransportOptions { Mode = "receptionist" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps);
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient);
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,4 +174,210 @@ public class MeshTransportOptionsValidatorTests
|
||||
],
|
||||
}).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- Grpc mode (ClusterClient→gRPC migration, Phase 0) --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Grpc_is_an_accepted_mode()
|
||||
{
|
||||
Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_mode_matching_is_case_insensitive()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.Mode = "grpc";
|
||||
|
||||
Validate(options, "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_without_a_psk_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = null;
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
|
||||
result.FailureMessage.ShouldContain("MeshTransport__GrpcPsk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_with_an_empty_psk_fails()
|
||||
{
|
||||
// Empty is the shape an unset environment variable actually produces, and it is exactly as
|
||||
// fatal as null — a blank bearer authenticates nothing.
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = " ";
|
||||
|
||||
Validate(options, "driver", "cluster-SITE-A").Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_psk_value_is_never_echoed_in_a_failure_message()
|
||||
{
|
||||
// The whole point of a validator that reads a secret is that its diagnostics are safe to
|
||||
// paste into a ticket. Naming the key is the contract; printing it is a leak.
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = "super-secret-mesh-psk";
|
||||
options.CentralGrpcEndpoints = ["central-1:4055"];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldNotContain("super-secret-mesh-psk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_without_central_endpoints_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.CentralGrpcEndpoints = [];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_central_endpoint_that_is_not_an_http_url_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.CentralGrpcEndpoints = ["central-1:4055"];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("central-1:4055");
|
||||
result.FailureMessage.ShouldContain("http://");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_bad_central_endpoint_among_good_ones_still_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.CentralGrpcEndpoints = ["http://central-1:4055", "akka.tcp://otopcua@central-2:4053"];
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("akka.tcp://otopcua@central-2:4053");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_with_a_non_positive_dispatch_timeout_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.DispatchTimeoutSeconds = 0;
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:DispatchTimeoutSeconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_driver_node_with_a_non_positive_ack_timeout_fails()
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.AckTimeoutSeconds = -1;
|
||||
|
||||
var result = Validate(options, "driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:AckTimeoutSeconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_admin_only_node_needs_none_of_the_node_side_keys()
|
||||
{
|
||||
// The node-side surface is what a DRIVER uses to dial central and post its ack. An admin-only
|
||||
// central node dials nodes instead and resolves per-cluster keys at runtime, so requiring
|
||||
// these here would make an admin node carry a PSK and an endpoint list it never reads.
|
||||
Validate(
|
||||
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
||||
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grpc_role_less_node_needs_none_of_the_node_side_keys()
|
||||
{
|
||||
// Every in-process test host and every single-mesh node has no declared role. Requiring the
|
||||
// Grpc surface of them would turn a dark-switch value into a tree-wide config obligation.
|
||||
Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc }).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roles_come_from_OTOPCUA_ROLES_when_Cluster_Roles_is_empty()
|
||||
{
|
||||
// Roles-source divergence (Gitea #532): a node configured only through the env var is still a
|
||||
// driver node in Akka. A validator reading Cluster:Roles alone would exempt the exact node
|
||||
// that has to dial central, and its acks would be rejected unauthenticated at runtime.
|
||||
using (new EnvVarScope("OTOPCUA_ROLES", "driver,cluster-SITE-A"))
|
||||
{
|
||||
var options = WellFormedGrpc();
|
||||
options.GrpcPsk = null;
|
||||
|
||||
Validate(options).Failed.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dps_mode_requires_none_of_the_new_Grpc_keys()
|
||||
{
|
||||
// The no-new-obligations invariant: adding a third mode must not make any existing
|
||||
// deployment's configuration invalid. A Dps driver node that sets nothing new still passes.
|
||||
Validate(
|
||||
new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps },
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterClient_mode_requires_none_of_the_new_Grpc_keys()
|
||||
{
|
||||
Validate(
|
||||
new MeshTransportOptions
|
||||
{
|
||||
Mode = MeshTransportOptions.ModeClusterClient,
|
||||
CentralContactPoints = ["akka.tcp://otopcua@central-1:4053"],
|
||||
},
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterPsks_defaults_to_an_empty_case_insensitive_map()
|
||||
{
|
||||
// Production leaves the map empty and the runtime provider falls back to the Secrets store;
|
||||
// the rig populates it. Case-insensitivity matters because a ClusterId reaches this lookup
|
||||
// from three sources — a role suffix, a DB column and an env key — that do not agree on case.
|
||||
var options = new MeshTransportOptions();
|
||||
|
||||
options.ClusterPsks.ShouldBeEmpty();
|
||||
options.ClusterPsks["MAIN"] = "rig-key";
|
||||
options.ClusterPsks.ContainsKey("main").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClusterPsks_stays_case_insensitive_after_binding_configuration()
|
||||
{
|
||||
// Asserting the comparer on a fresh instance proves nothing about the shape that reaches
|
||||
// production: the options binder could construct its own Dictionary and drop the comparer
|
||||
// silently, and the symptom would be a rig lookup that misses on a ClusterId spelled the way
|
||||
// the DB spells it. Bind for real and check the comparer survived.
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeshTransport:ClusterPsks:SITE-A"] = "rig-key",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = new MeshTransportOptions();
|
||||
configuration.GetSection(MeshTransportOptions.SectionName).Bind(options);
|
||||
|
||||
options.ClusterPsks["site-a"].ShouldBe("rig-key");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,58 @@ public class SplitTopologyTransportValidatorTests
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeClusterClient);
|
||||
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
||||
result.FailureMessage.ShouldContain("SITE-A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_driver_node_on_Grpc_mesh_transport_passes()
|
||||
{
|
||||
// The migration window: Grpc is the transport this rule exists to arrive at, so a rig node
|
||||
// flipped to it must boot. Without this relaxation the very first Grpc rollout step — setting
|
||||
// the mode on a split-topology node — is a host-start failure.
|
||||
Validate(
|
||||
meshMode: MeshTransportOptions.ModeGrpc,
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: null,
|
||||
"driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_admin_node_on_Grpc_mesh_transport_passes()
|
||||
{
|
||||
Validate(
|
||||
meshMode: MeshTransportOptions.ModeGrpc,
|
||||
telemetryMode: null,
|
||||
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
||||
"admin", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_node_on_Grpc_mesh_transport_is_matched_case_insensitively()
|
||||
{
|
||||
Validate(
|
||||
meshMode: "grpc",
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: TelemetryDialOptions.ModeGrpc,
|
||||
"admin", "driver", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_node_on_an_unrecognised_mesh_transport_still_fails()
|
||||
{
|
||||
// Widening the rule from one accepted value to two must not widen it to "anything that is not
|
||||
// Dps" — a typo'd mode is still a mode that delivers nothing across the boundary.
|
||||
var result = Validate(
|
||||
meshMode: "receptionist",
|
||||
telemetryMode: TelemetryOptions.ModeGrpc,
|
||||
telemetryDialMode: null,
|
||||
"driver", "cluster-SITE-A");
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_role_driver_node_fully_configured_passes()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user