feat(heidenhain): read-only, credential and endpoint deploy gates + TagConfig inspector (#803)
- HeidenhainDeployChecks (wired into DraftValidator): HeidenhainTagWritable refuses a ReadWrite access level on any Heidenhain tag, so no node is ever advertised writable; HeidenhainCredentialPersisted refuses a literal password in DriverConfig/DeviceConfig (only blank / secret: / env: references; the value is never echoed); HeidenhainConfigInvalid for out-of-range settings or a tagged device without an endpoint. - EquipmentTagConfigInspector maps Heidenhain to HeidenhainTagDefinitionFactory.Inspect. - Driver validates only devices that carry tags (an unconfigured default Device1 no longer faults the driver).
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Heidenhain LSV2 deploy gates (lmxopcua#803). All reuse the driver's own Contracts parser, so the gate and
|
||||
/// the runtime cannot disagree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item><b><see cref="WritableCode"/></b> — the driver is read-only (no <c>IWritable</c>), so a ReadWrite
|
||||
/// access level would advertise a writable OPC node that can never be written. Refused, so no Heidenhain
|
||||
/// node is ever writable.</item>
|
||||
/// <item><b><see cref="CredentialCode"/></b> — a password field that is not blank / <c>secret:</c> /
|
||||
/// <c>env:</c> would store a controller password in the config database (replicated to every node). The
|
||||
/// message never echoes the value.</item>
|
||||
/// <item><b><see cref="ConfigCode"/></b> — out-of-range driver settings, or an invalid endpoint on a device
|
||||
/// that carries tags (the driver would fail to start).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public static class HeidenhainDeployChecks
|
||||
{
|
||||
/// <summary>A Heidenhain tag authored ReadWrite.</summary>
|
||||
public const string WritableCode = "HeidenhainTagWritable";
|
||||
|
||||
/// <summary>A literal password in a Heidenhain DriverConfig/DeviceConfig.</summary>
|
||||
public const string CredentialCode = "HeidenhainCredentialPersisted";
|
||||
|
||||
/// <summary>Invalid Heidenhain settings or endpoint.</summary>
|
||||
public const string ConfigCode = "HeidenhainConfigInvalid";
|
||||
|
||||
/// <summary>Runs every Heidenhain gate.</summary>
|
||||
/// <param name="draft">The draft.</param>
|
||||
/// <returns>The errors.</returns>
|
||||
public static IReadOnlyList<ValidationError> Errors(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@
|
||||
<!-- lmxopcua#781: the Sql deploy checks reuse the driver's own tag parse. Cycle-safe — Sql.Contracts
|
||||
references only Core.Abstractions. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
<!-- lmxopcua#803: the Heidenhain read-only + credential deploy gates reuse the Contracts parser. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<string>();
|
||||
var perDevice = new Dictionary<HeidenhainDeviceOptions, List<HeidenhainTagDefinition>>();
|
||||
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<HeidenhainDeviceRuntime>();
|
||||
var byPath = new Dictionary<string, HeidenhainDeviceRuntime>(StringComparer.Ordinal);
|
||||
|
||||
+1
@@ -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,
|
||||
};
|
||||
|
||||
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates.</summary>
|
||||
[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<ValidationError> 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));
|
||||
}
|
||||
Reference in New Issue
Block a user