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:
Joseph Doherty
2026-11-10 23:10:36 -05:00
parent d50ee809e3
commit 2b45694f91
7 changed files with 185 additions and 9 deletions
@@ -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>