From d630a7e267892dcd4bda9b9cb06151a0b022765f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 11:29:45 -0400 Subject: [PATCH] feat(mesh-phase4): driver-only node must be FetchAndCache (validator) Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../ConfigSourceOptionsValidator.cs | 33 ++++++++++++ .../ConfigSourceOptionsValidatorTests.cs | 52 ++++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs index 9519a622..520044ef 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ConfigSourceOptionsValidator.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.OtOpcUa.Cluster; @@ -14,6 +15,20 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster; /// public sealed class ConfigSourceOptionsValidator : IValidateOptions { + private readonly IConfiguration _configuration; + + /// + /// DI-constructed by AddValidatedOptions (a plain AddSingleton), so a + /// constructor dependency is safe here. — not + /// IClusterRoleInfo — is the source of this node's roles: IClusterRoleInfo's + /// implementation needs the ActorSystem, which does not exist yet at + /// ValidateOnStart time. + /// + public ConfigSourceOptionsValidator(IConfiguration configuration) + { + _configuration = configuration; + } + /// public ValidateOptionsResult Validate(string? name, ConfigSourceOptions options) { @@ -76,6 +91,24 @@ public sealed class ConfigSourceOptionsValidator : IValidateOptions() ?? Array.Empty(); + var isDriver = Array.IndexOf(roles, "driver") >= 0; + var isAdmin = Array.IndexOf(roles, "admin") >= 0; + + if (isDriver && !isAdmin && isDirect) + { + errors.Add( + "Cluster:Roles is a driver-only node (has 'driver', not 'admin') but " + + $"{ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)} is 'Direct'. A " + + "driver-only node has no central ConfigDb to read from — it must use 'FetchAndCache'. " + + $"Set {ConfigSourceOptions.SectionName}:{nameof(ConfigSourceOptions.Mode)}=" + + $"{ConfigSourceOptions.ModeFetchAndCache} (see docs/Configuration.md → ConfigSource)."); + } + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(string.Join(" ", errors)); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs index 064e68e8..419f522d 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ConfigSourceOptionsValidatorTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Shouldly; using Xunit; @@ -11,8 +12,18 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests; /// public class ConfigSourceOptionsValidatorTests { - private static ValidateOptionsResult Validate(ConfigSourceOptions o) => - new ConfigSourceOptionsValidator().Validate(ConfigSourceOptions.SectionName, o); + private static ValidateOptionsResult Validate(ConfigSourceOptions o, params string[] roles) + { + var pairs = new Dictionary(); + for (var i = 0; i < roles.Length; i++) + { + pairs[$"Cluster:Roles:{i}"] = roles[i]; + } + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(pairs).Build(); + + return new ConfigSourceOptionsValidator(configuration).Validate(ConfigSourceOptions.SectionName, o); + } private static ConfigSourceOptions ValidFetch() => new() { @@ -117,4 +128,41 @@ public class ConfigSourceOptionsValidatorTests FetchTimeoutSeconds = 0, }).Succeeded.ShouldBeTrue(); } + + [Fact] + public void Driver_only_node_on_Direct_fails() + { + // A driver-only node has no central ConfigDb connection (Phase 4) — Direct silently produces a + // node that can never read its configuration. + var result = Validate( + new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect }, + "driver"); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain(ConfigSourceOptions.ModeFetchAndCache); + } + + [Fact] + public void Fused_admin_and_driver_node_on_Direct_is_valid() + { + // A fused node holds the ConfigDb connection via its admin role, so Direct is still legitimate. + Validate( + new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect }, + "admin", "driver").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Driver_only_node_on_FetchAndCache_is_valid() + { + Validate(ValidFetch(), "driver").Succeeded.ShouldBeTrue(); + } + + [Fact] + public void Admin_only_node_on_Direct_is_valid() + { + // An admin-only node has no driver role at all, so the driver-only rule does not apply to it. + Validate( + new ConfigSourceOptions { Mode = ConfigSourceOptions.ModeDirect }, + "admin").Succeeded.ShouldBeTrue(); + } }