409 lines
17 KiB
C#
409 lines
17 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
|
|
|
/// <summary>
|
|
/// Every failure this validator catches otherwise surfaces as an <i>absence</i> — a deployment
|
|
/// that never arrives, an alarm ack that does nothing, a node whose ack is dropped without a
|
|
/// 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, params string[] roles) =>
|
|
Validate(o, new Dictionary<string, string?>(), roles);
|
|
|
|
/// <summary>
|
|
/// As <see cref="Validate(MeshTransportOptions, string[])"/>, plus arbitrary configuration
|
|
/// keys — the admin-side rule cross-reads <c>ConfigServe:GrpcListenPort</c>, which lives
|
|
/// outside this options class entirely.
|
|
/// </summary>
|
|
private static ValidateOptionsResult Validate(
|
|
MeshTransportOptions o, Dictionary<string, string?> extra, params string[] roles)
|
|
{
|
|
var pairs = new Dictionary<string, string?>(extra);
|
|
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, EffectiveRoles.Resolve(configuration))
|
|
.Validate(MeshTransportOptions.SectionName, o);
|
|
}
|
|
|
|
private static Dictionary<string, string?> ConfigServePort(string? value) =>
|
|
new() { ["ConfigServe:GrpcListenPort"] = value };
|
|
|
|
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()
|
|
{
|
|
// The default is Dps — the pre-Phase-2 transport. A node that never sets this section must
|
|
// behave exactly as it did before, which is the whole premise of the dark switch.
|
|
Validate(new MeshTransportOptions()).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_mode_fails()
|
|
{
|
|
var result = Validate(new MeshTransportOptions { Mode = "not-a-transport" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("not-a-transport");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_mode_message_names_both_accepted_values()
|
|
{
|
|
// An operator who mistypes the mode gets the full menu, not a partial one.
|
|
var result = Validate(new MeshTransportOptions { Mode = "not-a-mode" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeDps);
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_retired_ClusterClient_mode_fails_rather_than_falling_back_to_Dps()
|
|
{
|
|
// A node left carrying the deleted transport's mode value must be refused at boot. Silently
|
|
// treating it as Dps would leave a split-topology node running, listening, and delivering
|
|
// nothing across its mesh boundary — the exact absence this validator exists to prevent.
|
|
var result = Validate(new MeshTransportOptions { Mode = "ClusterClient" });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("MeshTransport:Mode");
|
|
result.FailureMessage.ShouldContain(MeshTransportOptions.ModeGrpc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Mode_matching_is_case_insensitive()
|
|
{
|
|
Validate(new MeshTransportOptions { Mode = "dps" }).Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_positive_contact_refresh_fails()
|
|
{
|
|
var result = Validate(new MeshTransportOptions { ContactRefreshSeconds = 0 });
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain(nameof(MeshTransportOptions.ContactRefreshSeconds));
|
|
}
|
|
|
|
// ---- Grpc mode (the cross-mesh command plane) -----------------------------------------------
|
|
|
|
[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.
|
|
// (The ConfigServe port IS required of an admin node under Grpc — supplied here so this test
|
|
// stays about the node-side keys and nothing else.)
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("4055"),
|
|
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_without_a_config_serve_listener_fails()
|
|
{
|
|
// Central hosts CentralCommandService on the ConfigServe h2c listener, so an unset port means
|
|
// the service is never mapped and every node's ack has nowhere to land. Nothing errors at
|
|
// runtime — the deployment simply times out naming nodes that applied perfectly, which is the
|
|
// exact absence this validator exists to convert into a refusal to start.
|
|
var result = Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
"admin", "cluster-MAIN");
|
|
|
|
result.Failed.ShouldBeTrue();
|
|
result.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort");
|
|
result.FailureMessage.ShouldContain("ConfigServe__GrpcListenPort");
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_with_a_zero_config_serve_port_fails()
|
|
{
|
|
// 0 is the documented "disabled" value, not merely a missing key — it must fail exactly as an
|
|
// absent one does, because it binds nothing just as surely.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("0"),
|
|
"admin", "cluster-MAIN").Failed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_with_an_unparseable_config_serve_port_fails()
|
|
{
|
|
// A typo'd port binds nothing, so it belongs on the same side of the line as an absent one.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("40o55"),
|
|
"admin", "cluster-MAIN").Failed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_admin_node_with_a_config_serve_listener_passes()
|
|
{
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc },
|
|
ConfigServePort("4055"),
|
|
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Grpc_driver_only_node_needs_no_config_serve_listener()
|
|
{
|
|
// A driver node dials central; it serves no central command endpoint at all, and requiring an
|
|
// admin-side listener port of it would demand configuration it never reads.
|
|
Validate(WellFormedGrpc(), "driver", "cluster-SITE-A").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Dps_admin_node_needs_no_config_serve_listener()
|
|
{
|
|
// The no-new-obligations invariant, admin side: a central node still on the default transport
|
|
// publishes on DistributedPubSub and hosts no command endpoint.
|
|
Validate(
|
|
new MeshTransportOptions { Mode = MeshTransportOptions.ModeDps },
|
|
"admin", "cluster-MAIN").Succeeded.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void The_admin_role_is_also_read_from_OTOPCUA_ROLES()
|
|
{
|
|
// Same roles-source divergence as the driver case (Gitea #532), on the side that hosts the
|
|
// endpoint: a central node configured only through the env var is still the node whose
|
|
// listener every ack depends on.
|
|
using (new EnvVarScope("OTOPCUA_ROLES", "admin,cluster-MAIN"))
|
|
{
|
|
Validate(new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc })
|
|
.Failed.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 Grpc_fused_admin_and_driver_node_must_satisfy_BOTH_surfaces()
|
|
{
|
|
// The central pair is fused (admin,driver,cluster-MAIN), so it is the one node shape that owes
|
|
// both halves at once — and the two blocks are independent `if`s, not an `if`/`else if`. Were
|
|
// they chained, a fused node would be graded on the driver surface only and its missing
|
|
// ConfigServe listener would pass validation, leaving central with no place for any node's ack
|
|
// to land. Asserted from BOTH sides so a future chaining in either order is caught.
|
|
var missingNodeSide = new MeshTransportOptions { Mode = MeshTransportOptions.ModeGrpc };
|
|
var withPort = Validate(missingNodeSide, ConfigServePort("4055"), "driver", "admin", "cluster-MAIN");
|
|
withPort.Failed.ShouldBeTrue();
|
|
withPort.FailureMessage.ShouldContain("MeshTransport:GrpcPsk");
|
|
withPort.FailureMessage.ShouldContain("MeshTransport:CentralGrpcEndpoints");
|
|
|
|
var missingAdminSide = Validate(WellFormedGrpc(), "driver", "admin", "cluster-MAIN");
|
|
missingAdminSide.Failed.ShouldBeTrue();
|
|
missingAdminSide.FailureMessage.ShouldContain("ConfigServe:GrpcListenPort");
|
|
|
|
// And the fully-configured fused node passes — the rule is "both", not "neither".
|
|
Validate(WellFormedGrpc(), ConfigServePort("4055"), "driver", "admin", "cluster-MAIN")
|
|
.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");
|
|
}
|
|
}
|