feat(deploy): reject Tag/VirtualTag NodeId collisions at deploy (surgical DraftValidator gate)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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>
|
||||
/// Verifies <see cref="DraftSnapshotFactory.FromConfigDbAsync"/> materialises a
|
||||
/// <see cref="DraftSnapshot"/> from the live config DB whose Tag/VirtualTag rows feed the
|
||||
/// equipment-signal collision rule — the one rule wired into the deploy gate (Task 3).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftSnapshotFactoryTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
/// <summary>Initializes a new instance with an isolated in-memory config DB.</summary>
|
||||
public DraftSnapshotFactoryTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"draft-snapshot-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
/// <summary>Disposes the database context.</summary>
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
/// <summary>Seeds one Equipment plus a Tag and a VirtualTag sharing (EquipmentId, Name); the
|
||||
/// snapshot must carry both signal collections AND the validator must flag the collision.</summary>
|
||||
[Fact]
|
||||
public async Task FromConfigDb_populates_Tags_and_VirtualTags_and_surfaces_collision()
|
||||
{
|
||||
SeedEquipment("eq-1");
|
||||
_db.Tags.Add(BuildTag(equipmentId: "eq-1", name: "speed"));
|
||||
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "speed"));
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
|
||||
|
||||
snapshot.Tags.Count.ShouldBe(1);
|
||||
snapshot.VirtualTags.Count.ShouldBe(1);
|
||||
DraftValidator.Validate(snapshot).ShouldContain(e => e.Code == "EquipmentSignalNameCollision");
|
||||
}
|
||||
|
||||
/// <summary>A Tag and a VirtualTag with distinct names under the same equipment do not collide,
|
||||
/// so the snapshot validates clean of the collision code.</summary>
|
||||
[Fact]
|
||||
public async Task FromConfigDb_no_collision_when_names_differ()
|
||||
{
|
||||
SeedEquipment("eq-1");
|
||||
_db.Tags.Add(BuildTag(equipmentId: "eq-1", name: "speed"));
|
||||
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "temperature"));
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
|
||||
|
||||
snapshot.Tags.Count.ShouldBe(1);
|
||||
snapshot.VirtualTags.Count.ShouldBe(1);
|
||||
DraftValidator.Validate(snapshot).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision");
|
||||
}
|
||||
|
||||
private void SeedEquipment(string equipmentId)
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentUuid = uuid,
|
||||
EquipmentId = equipmentId,
|
||||
Name = "eq",
|
||||
DriverInstanceId = "d",
|
||||
UnsLineId = "line-a",
|
||||
MachineCode = "m",
|
||||
});
|
||||
}
|
||||
|
||||
private static Tag BuildTag(string equipmentId, string name) => new()
|
||||
{
|
||||
TagId = $"tag-{name}",
|
||||
DriverInstanceId = "d",
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new()
|
||||
{
|
||||
VirtualTagId = $"vtag-{name}",
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = "Float",
|
||||
ScriptId = "s-1",
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,60 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
||||
db.ConfigEdits.Single().EntityType.ShouldBe("Deployment");
|
||||
}
|
||||
|
||||
/// <summary>Verifies the surgical DraftValidator gate: a Tag↔VirtualTag NodeId collision in
|
||||
/// the live config rejects the deploy (422-mapped <see cref="StartDeploymentOutcome.Rejected"/>)
|
||||
/// before any coordinator dispatch — and inserts no Deployment row.</summary>
|
||||
[Fact]
|
||||
public void StartDeployment_rejects_on_Tag_VirtualTag_NodeId_collision()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
using (var db = dbFactory.CreateDbContext())
|
||||
{
|
||||
db.Equipment.Add(new Configuration.Entities.Equipment
|
||||
{
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
EquipmentId = "eq-1",
|
||||
Name = "eq",
|
||||
DriverInstanceId = "d",
|
||||
UnsLineId = "line-a",
|
||||
MachineCode = "m",
|
||||
});
|
||||
db.Tags.Add(new Configuration.Entities.Tag
|
||||
{
|
||||
TagId = "tag-speed",
|
||||
DriverInstanceId = "d",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "speed",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.VirtualTags.Add(new Configuration.Entities.VirtualTag
|
||||
{
|
||||
VirtualTagId = "vtag-speed",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "speed",
|
||||
DataType = "Float",
|
||||
ScriptId = "s-1",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var coordinator = CreateTestProbe("coord");
|
||||
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
||||
|
||||
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
|
||||
reply.Message.ShouldNotBeNull();
|
||||
reply.Message.ShouldContain("collide"); // the rule's message text
|
||||
|
||||
using var verify = dbFactory.CreateDbContext();
|
||||
verify.Deployments.Count().ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that starting a deployment is refused when another is in flight.</summary>
|
||||
[Fact]
|
||||
public void StartDeployment_refuses_when_another_is_in_flight()
|
||||
|
||||
Reference in New Issue
Block a user