v3 B1-WP4/WP5: pipeline rewire to greenfield schema + dark address space

- ConfigComposer: snapshot v3 tables (RawFolders/TagGroups/UnsTagReferences),
  drop the retired Namespaces snapshot; RevisionHash follows the new blob.
- DeploymentArtifact: compute each raw tag's RawPath (RawFolder->Driver->Device->
  TagGroup ancestry via shared RawPathResolver); per-driver RawTags + merged
  DriverConfig+DeviceConfig config injected into DriverInstanceSpec via
  DriverDeviceConfigMerger; ParseComposition emits an empty EquipmentTags set
  (DARK until Batch 4); EquipmentNode driver/device hooks always null; cluster
  scoping attributes equipment by UNS line only.
- AddressSpaceComposer: rewritten to the v3 shape (no Namespace/NamespaceKind,
  no dropped Tag/Equipment fields); emits empty EquipmentTags (parity mirror).
- DriverHostActor: fan-out/write map tuple member FullName -> RawPath.
- Commons: new RawPathResolver (shared identity authority) + DriverDeviceConfigMerger
  (single/multi-device endpoint+RawTags merge).
- DraftValidator: v3 rules — raw-name charset (RawPaths.ValidateSegment),
  historized effective-tagname <=255, UNS effective-leaf uniqueness across
  UnsTagReference/VirtualTag/ScriptedAlarm; DraftSnapshot(+Factory) carry the new tables.
- AdminOperationsActor: tag-config inspection resolves driver via Device (no
  EquipmentId/DriverInstanceId on Tag).
- Tests: RawPathResolver/merger unit tests (Commons.Tests, green) +
  DeploymentArtifact RawPath/dark test (Runtime.Tests, awaits WP6 corpus fixes).
This commit is contained in:
Joseph Doherty
2026-07-15 20:45:24 -04:00
parent 8e8dd2e824
commit e81ac352ed
12 changed files with 749 additions and 307 deletions
@@ -0,0 +1,99 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// v3 endpoint→<c>DeviceConfig</c> merge authority. Folds a driver's <c>DriverConfig</c> JSON
/// (protocol/channel-level settings) together with its per-<c>Device</c> <c>DeviceConfig</c> JSON
/// (host/endpoint + per-device settings — the Kepware channel/device split) plus the driver's authored
/// raw tags into the SINGLE config blob every driver factory binds from. Shared by the deploy-decode
/// seam (<c>DeploymentArtifact</c>, which builds each <c>DriverInstanceSpec.DriverConfig</c>) and — in
/// Batch 2 — the browse modal (which needs the same merged shape as the browser's <c>configJson</c>),
/// so the merge lives in exactly one place.
/// <para><b>Contract.</b> Output = the parsed <c>DriverConfig</c> object with, in order:
/// <list type="number">
/// <item>When there is EXACTLY ONE device, its <c>DeviceConfig</c> keys shallow-merged up to the top
/// level (DeviceConfig wins) — so a single-endpoint driver's endpoint (<c>Host</c>/<c>Port</c>,
/// <c>EndpointUrl</c>, gateway address, …) lands where its options bind. Multiple devices do NOT
/// merge up (their endpoints are per-device, read from the array below).</item>
/// <item>A reconstructed <c>Devices</c> array — one object per device = its <c>DeviceConfig</c> keys
/// plus <c>DeviceName</c> = the device's entity <c>Name</c> (the RawPath device segment /
/// <see cref="RawTagEntry.DeviceName"/> routing key). Multi-device drivers read this; single-endpoint
/// drivers ignore the unknown key.</item>
/// <item>A <c>RawTags</c> array = the driver's authored <see cref="RawTagEntry"/> list, so
/// <c>options.RawTags</c> binds.</item>
/// </list>
/// Existing top-level protocol/channel keys survive (single-device DeviceConfig keys override on a name
/// clash — the documented Kepware precedence). Never throws on malformed member JSON: a blank/invalid
/// <c>DriverConfig</c> or <c>DeviceConfig</c> degrades to an empty object for that member.</para>
/// </summary>
public static class DriverDeviceConfigMerger
{
private static readonly JsonSerializerOptions SerializeOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
};
/// <summary>The device rows feeding the merge: the entity <c>Name</c> (routing key) + schemaless <c>DeviceConfig</c> JSON.</summary>
/// <param name="Name">The device's entity <c>Name</c> — the RawPath device segment and multi-device routing key.</param>
/// <param name="DeviceConfig">The device's schemaless <c>DeviceConfig</c> JSON (endpoint + per-device settings).</param>
public readonly record struct DeviceRow(string Name, string? DeviceConfig);
/// <summary>Merge a driver's config + its devices' configs + its authored raw tags into one config blob.</summary>
/// <param name="driverConfigJson">The driver-level <c>DriverConfig</c> JSON (protocol/channel settings).</param>
/// <param name="devices">The driver's device rows, in deterministic order (the sole device merges up).</param>
/// <param name="rawTags">The driver's authored raw tags to inject as the <c>RawTags</c> array.</param>
/// <returns>The single merged config JSON string the driver factory binds from.</returns>
public static string Merge(
string? driverConfigJson,
IReadOnlyList<DeviceRow> devices,
IReadOnlyList<RawTagEntry> rawTags)
{
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(rawTags);
var root = ParseObject(driverConfigJson);
// (1) Exactly one device ⇒ shallow-merge its DeviceConfig up to the top level (DeviceConfig wins),
// so a single-endpoint driver's endpoint lands where its options bind. Multiple devices are
// per-device only (read from the Devices array below), so no merge-up.
if (devices.Count == 1)
{
var only = ParseObject(devices[0].DeviceConfig);
foreach (var (key, value) in only)
root[key] = value?.DeepClone();
}
// (2) Reconstruct the Devices array from ALL device rows: DeviceConfig keys + DeviceName = entity Name.
var devicesArray = new JsonArray();
foreach (var device in devices)
{
var obj = ParseObject(device.DeviceConfig);
obj["DeviceName"] = device.Name; // entity Name is the RawPath device segment + routing key
devicesArray.Add(obj);
}
root["Devices"] = devicesArray;
// (3) Inject the authored raw tags so options.RawTags binds.
root["RawTags"] = JsonSerializer.SerializeToNode(rawTags, SerializeOptions);
return root.ToJsonString(SerializeOptions);
}
/// <summary>Parse JSON into a mutable <see cref="JsonObject"/>; blank / non-object / malformed ⇒ a fresh empty object.</summary>
private static JsonObject ParseObject(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new JsonObject();
try
{
return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
return new JsonObject();
}
}
}
@@ -0,0 +1,132 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Resolves a raw <c>Tag</c>'s v3 <c>RawPath</c> — the single identity string
/// <c>&lt;Folder/…&gt;/&lt;Driver&gt;/&lt;Device&gt;/&lt;TagGroup/…&gt;/&lt;Tag&gt;</c> — from the raw
/// topology's ancestry maps, and a driver's RawPath prefix (folders + driver name). Pure + input-shape
/// agnostic: callers build the four id→ancestry maps from whatever they have (EF entities on the
/// authoring/validation side, artifact JSON on the deploy-decode side) and both sides then agree
/// byte-for-byte because the segment order + join live ONLY here (over <see cref="RawPaths"/>).
/// <para>Every path is built through <see cref="RawPaths.Combine"/>, so segment charset (no
/// <c>/</c>, no lead/trail whitespace) is enforced in one place. A broken link (missing device / driver
/// lookup, or an invalid segment) yields <see langword="null"/> — never a throw — so a malformed
/// artifact degrades to "this tag has no RawPath" (dropped) rather than faulting a whole deploy decode.
/// Ancestry walks are depth-bounded to survive a corrupt parent cycle.</para>
/// </summary>
public sealed class RawPathResolver
{
private const int MaxAncestryDepth = 256;
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _folders;
private readonly IReadOnlyDictionary<string, (string? RawFolderId, string Name)> _drivers;
private readonly IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> _devices;
private readonly IReadOnlyDictionary<string, (string? ParentId, string Name)> _groups;
/// <summary>Initializes a new <see cref="RawPathResolver"/> over the raw topology's ancestry maps.</summary>
/// <param name="folders"><c>RawFolderId → (ParentRawFolderId?, Name)</c>. <see langword="null"/> parent = cluster root.</param>
/// <param name="drivers"><c>DriverInstanceId → (RawFolderId?, Name)</c>. <see langword="null"/> folder = cluster root.</param>
/// <param name="devices"><c>DeviceId → (DriverInstanceId, Name)</c>.</param>
/// <param name="groups"><c>TagGroupId → (ParentTagGroupId?, Name)</c>. <see langword="null"/> parent = directly under the device.</param>
public RawPathResolver(
IReadOnlyDictionary<string, (string? ParentId, string Name)> folders,
IReadOnlyDictionary<string, (string? RawFolderId, string Name)> drivers,
IReadOnlyDictionary<string, (string DriverInstanceId, string Name)> devices,
IReadOnlyDictionary<string, (string? ParentId, string Name)> groups)
{
ArgumentNullException.ThrowIfNull(folders);
ArgumentNullException.ThrowIfNull(drivers);
ArgumentNullException.ThrowIfNull(devices);
ArgumentNullException.ThrowIfNull(groups);
_folders = folders;
_drivers = drivers;
_devices = devices;
_groups = groups;
}
/// <summary>
/// Build the RawPath prefix a driver contributes to every child tag: its folder ancestry
/// (root → leaf) followed by the driver name. Returns <see langword="null"/> when the driver id is
/// unknown or any segment/link is invalid.
/// </summary>
/// <param name="driverInstanceId">The driver instance id.</param>
/// <returns>The driver RawPath prefix, or <see langword="null"/> on a broken/unknown chain.</returns>
public string? TryBuildDriverPrefix(string driverInstanceId)
{
if (driverInstanceId is null || !_drivers.TryGetValue(driverInstanceId, out var driver)) return null;
try
{
var path = BuildFolderChain(driver.RawFolderId);
return RawPaths.Combine(path, driver.Name);
}
catch (ArgumentException)
{
return null;
}
}
/// <summary>
/// Build a raw tag's full RawPath:
/// <c>&lt;driver-prefix&gt;/&lt;Device&gt;/&lt;TagGroup ancestry root→leaf&gt;/&lt;Tag&gt;</c>.
/// Returns <see langword="null"/> when the device / driver lookup is missing or any segment/link is
/// invalid.
/// </summary>
/// <param name="deviceId">The tag's owning device id.</param>
/// <param name="tagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
/// <param name="tagName">The tag's leaf name.</param>
/// <returns>The tag's RawPath, or <see langword="null"/> on a broken/unknown chain.</returns>
public string? TryBuildTagPath(string deviceId, string? tagGroupId, string tagName)
{
if (deviceId is null || !_devices.TryGetValue(deviceId, out var device)) return null;
var driverPrefix = TryBuildDriverPrefix(device.DriverInstanceId);
if (driverPrefix is null) return null;
try
{
var path = RawPaths.Combine(driverPrefix, device.Name);
path = AppendGroupChain(path, tagGroupId);
return RawPaths.Combine(path, tagName);
}
catch (ArgumentException)
{
return null;
}
}
/// <summary>Build a folder ancestry path (root → leaf) for the given leaf folder id (null = cluster root ⇒ blank).</summary>
private string? BuildFolderChain(string? leafFolderId)
{
if (leafFolderId is null) return null;
var names = new List<string>();
var current = leafFolderId;
var depth = 0;
while (current is not null && depth++ < MaxAncestryDepth)
{
if (!_folders.TryGetValue(current, out var folder))
throw new ArgumentException($"Unknown RawFolder '{current}'.");
names.Add(folder.Name);
current = folder.ParentId;
}
names.Reverse();
return names.Count == 0 ? null : RawPaths.Build(names);
}
/// <summary>Append a tag-group ancestry path (root → leaf) onto <paramref name="parentPath"/> (null group ⇒ unchanged).</summary>
private string AppendGroupChain(string parentPath, string? leafGroupId)
{
if (leafGroupId is null) return parentPath;
var names = new List<string>();
var current = leafGroupId;
var depth = 0;
while (current is not null && depth++ < MaxAncestryDepth)
{
if (!_groups.TryGetValue(current, out var group))
throw new ArgumentException($"Unknown TagGroup '{current}'.");
names.Add(group.Name);
current = group.ParentId;
}
names.Reverse();
var path = parentPath;
foreach (var name in names)
path = RawPaths.Combine(path, name);
return path;
}
}