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;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||||
@@ -78,6 +79,27 @@ public sealed class AdminOperationsActor : ReceiveActor
|
|||||||
return;
|
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 artifact = await ConfigComposer.SnapshotAndFlattenAsync(db);
|
||||||
var deploymentId = DeploymentId.NewId();
|
var deploymentId = DeploymentId.NewId();
|
||||||
var revHash = RevisionHash.Parse(artifact.RevisionHash);
|
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");
|
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>
|
/// <summary>Verifies that starting a deployment is refused when another is in flight.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartDeployment_refuses_when_another_is_in_flight()
|
public void StartDeployment_refuses_when_another_is_in_flight()
|
||||||
|
|||||||
Reference in New Issue
Block a user