feat(deploy): reject Tag/VirtualTag NodeId collisions at deploy (surgical DraftValidator gate)

This commit is contained in:
Joseph Doherty
2026-06-07 10:42:13 -04:00
parent fce66d104a
commit 1023209d52
4 changed files with 223 additions and 0 deletions
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// Materialises a <see cref="DraftSnapshot"/> from the live config DB so
/// <see cref="DraftValidator"/> can run against the current edit state at deploy time.
/// </summary>
/// <remarks>
/// <para>
/// This is a whole-DB ("global") snapshot — every cluster's rows in one pass — which is
/// what the deploy path needs: the admin-operations actor snapshots and flattens the
/// entire config, not one cluster. The validator's rules compare entity-level
/// <c>ClusterId</c> fields against each other (e.g. namespace binding), so the snapshot's
/// own <see cref="DraftSnapshot.ClusterId"/> is not read by any rule and is left empty.
/// </para>
/// <para>
/// <see cref="DraftSnapshot.GenerationId"/> is a placeholder (the generation model was
/// dropped); no rule reads it. <see cref="DraftSnapshot.PriorEquipment"/> is empty because
/// there is no prior-generation table to diff against. <see cref="DraftSnapshot.Enterprise"/>
/// / <see cref="DraftSnapshot.Site"/> are left null so the path-length rule uses its
/// conservative upper bound.
/// </para>
/// </remarks>
public static class DraftSnapshotFactory
{
/// <summary>Builds a <see cref="DraftSnapshot"/> from the current config DB rows.</summary>
/// <param name="db">The config DB context to read from.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A snapshot populated from the live DB, ready for <see cref="DraftValidator.Validate"/>.</returns>
public static async Task<DraftSnapshot> FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default)
=> new DraftSnapshot
{
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [],
ActiveReservations = await db.ExternalIdReservations.AsNoTracking().ToListAsync(ct),
};
}
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -78,6 +79,27 @@ public sealed class AdminOperationsActor : ReceiveActor
return;
}
// Surgical pre-seal gate: reject only on a Tag↔VirtualTag NodeId collision. The other
// DraftValidator rules still run (one pass) but must NOT block here — they are dormant
// and the current non-canonical company overlay would otherwise fail them. Filter to the
// single collision code so a real OPC UA address-space clash can never be deployed.
var draft = await DraftSnapshotFactory.FromConfigDbAsync(db);
var collisions = DraftValidator.Validate(draft)
.Where(e => e.Code == "EquipmentSignalNameCollision")
.ToList();
if (collisions.Count > 0)
{
var summary = string.Join("; ", collisions.Select(e => e.Message));
_log.Warning("StartDeployment rejected (signal collision): {Summary}", summary);
replyTo.Tell(new StartDeploymentResult(
StartDeploymentOutcome.Rejected,
DeploymentId: null,
RevisionHash: null,
Message: summary,
msg.CorrelationId));
return;
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db);
var deploymentId = DeploymentId.NewId();
var revHash = RevisionHash.Parse(artifact.RevisionHash);