diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 88714a5fd..5f9ed4f6e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -46,6 +46,7 @@ public static class DraftValidator errors.AddRange(SqlTagDeployChecks.UndeclaredQueryErrors(draft)); errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft)); ValidateMTConnectSingleDevice(draft, errors); + errors.AddRange(HeidenhainDeployChecks.Errors(draft)); ValidateNodeAclScopes(draft, errors); return errors; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs new file mode 100644 index 000000000..0518155e8 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/HeidenhainDeployChecks.cs @@ -0,0 +1,92 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// Heidenhain LSV2 deploy gates (lmxopcua#803). All reuse the driver's own Contracts parser, so the gate and +/// the runtime cannot disagree. +/// +/// +/// +/// — the driver is read-only (no IWritable), so a ReadWrite +/// access level would advertise a writable OPC node that can never be written. Refused, so no Heidenhain +/// node is ever writable. +/// — a password field that is not blank / secret: / +/// env: would store a controller password in the config database (replicated to every node). The +/// message never echoes the value. +/// — out-of-range driver settings, or an invalid endpoint on a device +/// that carries tags (the driver would fail to start). +/// +/// +public static class HeidenhainDeployChecks +{ + /// A Heidenhain tag authored ReadWrite. + public const string WritableCode = "HeidenhainTagWritable"; + + /// A literal password in a Heidenhain DriverConfig/DeviceConfig. + public const string CredentialCode = "HeidenhainCredentialPersisted"; + + /// Invalid Heidenhain settings or endpoint. + public const string ConfigCode = "HeidenhainConfigInvalid"; + + /// Runs every Heidenhain gate. + /// The draft. + /// The errors. + public static IReadOnlyList Errors(DraftSnapshot draft) + { + var errors = new List(); + foreach (var driver in draft.DriverInstances) + { + if (!string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.Ordinal)) continue; + var devices = draft.Devices + .Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)) + .ToList(); + var deviceIds = devices.Select(d => d.DeviceId).ToHashSet(StringComparer.Ordinal); + var tags = draft.Tags.Where(t => t.DeviceId is not null && deviceIds.Contains(t.DeviceId)).ToList(); + + foreach (var tag in tags.Where(t => t.AccessLevel == TagAccessLevel.ReadWrite)) + { + errors.Add(new(WritableCode, + $"Tag '{tag.Name}' on Heidenhain driver '{driver.Name}' is ReadWrite. The Heidenhain LSV2 driver is " + + "read-only (only logins and read requests are ever sent); set the tag's access level to Read.", + tag.TagId)); + } + + HeidenhainDriverOptions options; + try + { + var merged = DriverDeviceConfigMerger.Merge( + driver.DriverConfig, + devices.Select(d => new DriverDeviceConfigMerger.DeviceRow(d.Name, d.DeviceConfig)).ToList(), + []); + options = HeidenhainOptionsParser.Parse(merged); + } + catch (InvalidOperationException ex) + { + errors.Add(new(ConfigCode, $"Heidenhain driver '{driver.Name}': {ex.Message}", driver.DriverInstanceId)); + continue; + } + + foreach (var problem in HeidenhainOptionsParser.Validate(options)) + { + var code = problem.Contains("password", StringComparison.OrdinalIgnoreCase) ? CredentialCode : ConfigCode; + errors.Add(new(code, $"Heidenhain driver '{driver.Name}': {problem}", driver.DriverInstanceId)); + } + + foreach (var device in devices) + { + if (!tags.Any(t => string.Equals(t.DeviceId, device.DeviceId, StringComparison.Ordinal))) continue; + var opts = options.Devices.FirstOrDefault(o => string.Equals(o.DeviceName, device.Name, StringComparison.Ordinal)); + var problems = opts is null ? ["Device has no endpoint."] : HeidenhainOptionsParser.ValidateDevice(opts); + foreach (var p in problems) + errors.Add(new(ConfigCode, $"Heidenhain device '{device.Name}': {p}", device.DeviceId)); + } + } + + return errors; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj index e153ea358..3d1bf2deb 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj @@ -47,6 +47,8 @@ + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs index f00f58e59..50b847df0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriver.cs @@ -134,16 +134,8 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi { var devices = _options.Devices ?? []; _skipped.BeginRebuild(_options.RawTags.Count); - var deviceErrors = new List(); var perDevice = new Dictionary>(); - foreach (var d in devices) - { - var errs = HeidenhainOptionsParser.ValidateDevice(d); - if (errs.Count > 0) deviceErrors.Add($"Device '{d.DeviceName ?? d.Host}': {string.Join(" ", errs)}"); - else perDevice[d] = []; - } - - if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors)); + foreach (var d in devices) perDevice[d] = []; foreach (var raw in _options.RawTags) { @@ -171,6 +163,15 @@ public sealed class HeidenhainDriver : IDriver, IReadable, ISubscribable, ITagDi list.Add(def); } + // A control is validated only when it carries tags: a freshly created /raw Device has an empty + // DeviceConfig, and must not fault the whole driver (or the deploy) before it is configured. + var deviceErrors = perDevice.Where(kv => kv.Value.Count > 0) + .Select(kv => (kv.Key, Errors: HeidenhainOptionsParser.ValidateDevice(kv.Key))) + .Where(x => x.Errors.Count > 0) + .Select(x => $"Device '{x.Key.DeviceName ?? x.Key.Host}': {string.Join(" ", x.Errors)}") + .ToList(); + if (deviceErrors.Count > 0) throw new InvalidOperationException(string.Join("; ", deviceErrors)); + _skipped.Commit(); var runtimes = new List(); var byPath = new Dictionary(StringComparer.Ordinal); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index d49bbc219..947f27084 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs @@ -65,6 +65,7 @@ public static class EquipmentTagConfigInspector [DriverTypeNames.MTConnect] = MTConnectTagDefinitionFactory.Inspect, [DriverTypeNames.Calculation] = CalculationTagDefinitionFactory.Inspect, [DriverTypeNames.OpcUaClient] = OpcUaClientTagDefinitionFactory.Inspect, + [DriverTypeNames.Heidenhain] = Driver.Heidenhain.HeidenhainTagDefinitionFactory.Inspect, }; /// Inspects a tag's TagConfig for the given driver type. Returns the driver's warnings, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index a1b2f9ab8..33e268cb2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -57,6 +57,7 @@ used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. --> + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs new file mode 100644 index 000000000..a71303447 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs @@ -0,0 +1,78 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates. +[Trait("Category", "Unit")] +public sealed class DraftValidatorHeidenhainTests +{ + private static DriverInstance Driver(string config = "{}", string type = "Heidenhain") => new() + { + DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = type, DriverConfig = config, + }; + + private static Device Dev(string config = """{"Host":"10.0.0.5"}""") => new() + { + DeviceId = "d1", DriverInstanceId = "hh", Name = "dev1", DeviceConfig = config, + }; + + private static Tag T(TagAccessLevel access) => new() + { + TagId = "t1", DeviceId = "d1", Name = "ExecState", DataType = "Int32", AccessLevel = access, + TagConfig = """{"address":"Status/ExecutionState"}""", + }; + + private static IReadOnlyList Validate(DriverInstance d, Device dev, params Tag[] tags) => + DraftValidator.Validate(new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", DriverInstances = [d], Devices = [dev], Tags = tags, + }); + + [Fact] + public void A_read_only_tag_on_a_configured_control_is_valid() + => Validate(Driver(), Dev(), T(TagAccessLevel.Read)).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); + + [Fact] + public void A_ReadWrite_tag_is_refused_so_no_node_is_ever_writable() + { + var e = Validate(Driver(), Dev(), T(TagAccessLevel.ReadWrite)).Single(x => x.Code == HeidenhainDeployChecks.WritableCode); + e.Context.ShouldBe("t1"); + e.Message.ShouldContain("read-only"); + } + + [Theory] + [InlineData("""{"Access":{"InspectPassword":"hunter2"}}""", "{\"Host\":\"10.0.0.5\"}")] + [InlineData("{}", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""")] + public void A_literal_password_is_refused_without_echoing_it(string driverConfig, string deviceConfig) + { + var errors = Validate(Driver(driverConfig), Dev(deviceConfig), T(TagAccessLevel.Read)); + errors.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode); + errors.ShouldAllBe(e => !e.Message.Contains("hunter2")); + } + + [Fact] + public void Secret_and_env_references_are_accepted() + => Validate(Driver("""{"Access":{"InspectPassword":"secret:lsv2/inspect","DncPassword":"env:LSV2_DNC"}}"""), Dev(), T(TagAccessLevel.Read)) + .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); + + [Fact] + public void A_device_with_tags_but_no_endpoint_is_refused_while_an_empty_device_is_not() + { + Validate(Driver(), Dev("{}"), T(TagAccessLevel.Read)).ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode && e.Context == "d1"); + Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); + } + + [Fact] + public void Out_of_range_settings_are_refused() + => Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read)) + .ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode); + + [Fact] + public void Other_drivers_are_unaffected() + => Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite)) + .ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal)); +}