feat(deploy): activate full DraftValidator gate (reject on any validation error)
This commit is contained in:
@@ -79,18 +79,19 @@ public sealed class AdminOperationsActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Surgical pre-seal gate: reject only on a Tag↔VirtualTag NodeId collision. The other
|
// Full pre-seal gate: run the complete DraftValidator (one pass, all rules) and reject
|
||||||
// DraftValidator rules still run (one pass) but must NOT block here — they are dormant
|
// on ANY validation error. The config is now canonical — the company overlay loader emits
|
||||||
// and the current non-canonical company overlay would otherwise fail them. Filter to the
|
// canonical EquipmentIds and the seed is clean — so every rule (UNS segments, EquipmentId
|
||||||
// single collision code so a real OPC UA address-space clash can never be deployed.
|
// derivation, cross-cluster/namespace binding, driver-namespace compat, signal collisions,
|
||||||
|
// …) gates the deploy. A green build in this repo does not prove the config is valid; this
|
||||||
|
// is the last guard before a bad address space (or a non-derived EquipmentId) ships.
|
||||||
var draft = await DraftSnapshotFactory.FromConfigDbAsync(db);
|
var draft = await DraftSnapshotFactory.FromConfigDbAsync(db);
|
||||||
var collisions = DraftValidator.Validate(draft)
|
var errors = DraftValidator.Validate(draft);
|
||||||
.Where(e => e.Code == "EquipmentSignalNameCollision")
|
if (errors.Count > 0)
|
||||||
.ToList();
|
|
||||||
if (collisions.Count > 0)
|
|
||||||
{
|
{
|
||||||
var summary = string.Join("; ", collisions.Select(e => e.Message));
|
var summary = string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}"));
|
||||||
_log.Warning("StartDeployment rejected (signal collision): {Summary}", summary);
|
_log.Warning("StartDeployment rejected ({Count} validation error(s)): {Summary}",
|
||||||
|
errors.Count, summary);
|
||||||
replyTo.Tell(new StartDeploymentResult(
|
replyTo.Tell(new StartDeploymentResult(
|
||||||
StartDeploymentOutcome.Rejected,
|
StartDeploymentOutcome.Rejected,
|
||||||
DeploymentId: null,
|
DeploymentId: null,
|
||||||
|
|||||||
@@ -186,6 +186,117 @@ public sealed class DraftValidatorTests
|
|||||||
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
|
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------------------
|
||||||
|
// Full-config probe — proves a realistic canonical deployed config passes ALL rules.
|
||||||
|
// This guards the deploy-path gate flip (reject-on-ANY-error): if a clean canonical
|
||||||
|
// config fired any rule, flipping the gate would block every deploy.
|
||||||
|
// ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Probe for the deploy-path gate activation: a full, realistic config modelling
|
||||||
|
/// the REAL deployed shape (a SystemPlatform namespace + GalaxyMxGateway driver + SystemPlatform
|
||||||
|
/// Tags with EquipmentId=null from the OtOpcUa seed, PLUS an Equipment namespace + Modbus driver
|
||||||
|
/// + UNS area/line + canonical Equipment rows + VirtualTags from the company overlay) must
|
||||||
|
/// produce ZERO validation errors so the reject-on-any-error gate is safe to activate.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Full_realistic_config_passes_all_rules()
|
||||||
|
{
|
||||||
|
// SystemPlatform side (OtOpcUa seed shape): Galaxy hierarchy, no Equipment, Tags carry EquipmentId=null.
|
||||||
|
var spNamespace = new Namespace
|
||||||
|
{
|
||||||
|
NamespaceId = "MAIN-OPCUA-systemplatform",
|
||||||
|
ClusterId = "MAIN",
|
||||||
|
Kind = NamespaceKind.SystemPlatform,
|
||||||
|
NamespaceUri = "urn:zb:main:systemplatform",
|
||||||
|
};
|
||||||
|
var spDriver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "main-galaxy",
|
||||||
|
ClusterId = "MAIN",
|
||||||
|
NamespaceId = spNamespace.NamespaceId,
|
||||||
|
Name = "Galaxy",
|
||||||
|
DriverType = "GalaxyMxGateway",
|
||||||
|
DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Equipment side (company overlay): Modbus driver in an Equipment namespace + UNS + canonical equipment.
|
||||||
|
var eqNamespace = new Namespace
|
||||||
|
{
|
||||||
|
NamespaceId = "MAIN-OPCUA-equipment",
|
||||||
|
ClusterId = "MAIN",
|
||||||
|
Kind = NamespaceKind.Equipment,
|
||||||
|
NamespaceUri = "urn:zb:main:equipment",
|
||||||
|
};
|
||||||
|
var eqDriver = new DriverInstance
|
||||||
|
{
|
||||||
|
DriverInstanceId = "main-modbus",
|
||||||
|
ClusterId = "MAIN",
|
||||||
|
NamespaceId = eqNamespace.NamespaceId,
|
||||||
|
Name = "Modbus",
|
||||||
|
DriverType = "Modbus",
|
||||||
|
DriverConfig = "{}",
|
||||||
|
};
|
||||||
|
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" };
|
||||||
|
|
||||||
|
// Canonical EquipmentIds — derived from the EquipmentUuid via the same rule the overlay loader uses.
|
||||||
|
var rinserUuid = Guid.NewGuid();
|
||||||
|
var fillerUuid = Guid.NewGuid();
|
||||||
|
var rinser = new Equipment
|
||||||
|
{
|
||||||
|
EquipmentUuid = rinserUuid,
|
||||||
|
EquipmentId = DraftValidator.DeriveEquipmentId(rinserUuid),
|
||||||
|
Name = "rinser-01",
|
||||||
|
DriverInstanceId = eqDriver.DriverInstanceId,
|
||||||
|
UnsLineId = line.UnsLineId,
|
||||||
|
MachineCode = "machine_001",
|
||||||
|
ZTag = null,
|
||||||
|
SAPID = null,
|
||||||
|
};
|
||||||
|
var filler = new Equipment
|
||||||
|
{
|
||||||
|
EquipmentUuid = fillerUuid,
|
||||||
|
EquipmentId = DraftValidator.DeriveEquipmentId(fillerUuid),
|
||||||
|
Name = "filler-02",
|
||||||
|
DriverInstanceId = eqDriver.DriverInstanceId,
|
||||||
|
UnsLineId = line.UnsLineId,
|
||||||
|
MachineCode = "machine_002",
|
||||||
|
ZTag = null,
|
||||||
|
SAPID = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
var draft = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = 0,
|
||||||
|
ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync
|
||||||
|
// Enterprise/Site left null — matches the deploy path's conservative fallback
|
||||||
|
Namespaces = [spNamespace, eqNamespace],
|
||||||
|
DriverInstances = [spDriver, eqDriver],
|
||||||
|
UnsAreas = [area],
|
||||||
|
UnsLines = [line],
|
||||||
|
Equipment = [rinser, filler],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
// SystemPlatform tags from the seed: EquipmentId null, Galaxy folder hierarchy.
|
||||||
|
BuildTag(equipmentId: null, name: "PV", folderPath: "Area.Tank01"),
|
||||||
|
BuildTag(equipmentId: null, name: "SP", folderPath: "Area.Tank01"),
|
||||||
|
],
|
||||||
|
VirtualTags =
|
||||||
|
[
|
||||||
|
// One per equipment, names distinct within each owning equipment.
|
||||||
|
BuildVirtualTag(equipmentId: rinser.EquipmentId, name: "oee"),
|
||||||
|
BuildVirtualTag(equipmentId: filler.EquipmentId, name: "throughput"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var errors = DraftValidator.Validate(draft);
|
||||||
|
|
||||||
|
errors.ShouldBeEmpty(
|
||||||
|
"a realistic canonical deployed config must pass every DraftValidator rule so the " +
|
||||||
|
"reject-on-any-error deploy gate is safe; firing rules: " +
|
||||||
|
string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}")));
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
// ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision
|
// ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -43,19 +43,24 @@ 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
|
/// <summary>Verifies the full DraftValidator gate (reject on ANY error): a Tag↔VirtualTag
|
||||||
/// the live config rejects the deploy (422-mapped <see cref="StartDeploymentOutcome.Rejected"/>)
|
/// NodeId collision in the live config rejects the deploy (422-mapped
|
||||||
/// before any coordinator dispatch — and inserts no Deployment row.</summary>
|
/// <see cref="StartDeploymentOutcome.Rejected"/>) before any coordinator dispatch — and inserts
|
||||||
|
/// no Deployment row. The colliding equipment uses a canonical EquipmentId so the rejection is
|
||||||
|
/// attributable to the collision rule, not to EquipmentIdNotDerived.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StartDeployment_rejects_on_Tag_VirtualTag_NodeId_collision()
|
public void StartDeployment_rejects_on_Tag_VirtualTag_NodeId_collision()
|
||||||
{
|
{
|
||||||
|
var uuid = Guid.NewGuid();
|
||||||
|
var equipmentId = Configuration.Validation.DraftValidator.DeriveEquipmentId(uuid);
|
||||||
|
|
||||||
var dbFactory = NewInMemoryDbFactory();
|
var dbFactory = NewInMemoryDbFactory();
|
||||||
using (var db = dbFactory.CreateDbContext())
|
using (var db = dbFactory.CreateDbContext())
|
||||||
{
|
{
|
||||||
db.Equipment.Add(new Configuration.Entities.Equipment
|
db.Equipment.Add(new Configuration.Entities.Equipment
|
||||||
{
|
{
|
||||||
EquipmentUuid = Guid.NewGuid(),
|
EquipmentUuid = uuid,
|
||||||
EquipmentId = "eq-1",
|
EquipmentId = equipmentId,
|
||||||
Name = "eq",
|
Name = "eq",
|
||||||
DriverInstanceId = "d",
|
DriverInstanceId = "d",
|
||||||
UnsLineId = "line-a",
|
UnsLineId = "line-a",
|
||||||
@@ -65,7 +70,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
|||||||
{
|
{
|
||||||
TagId = "tag-speed",
|
TagId = "tag-speed",
|
||||||
DriverInstanceId = "d",
|
DriverInstanceId = "d",
|
||||||
EquipmentId = "eq-1",
|
EquipmentId = equipmentId,
|
||||||
Name = "speed",
|
Name = "speed",
|
||||||
DataType = "Float",
|
DataType = "Float",
|
||||||
AccessLevel = TagAccessLevel.Read,
|
AccessLevel = TagAccessLevel.Read,
|
||||||
@@ -74,7 +79,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
|||||||
db.VirtualTags.Add(new Configuration.Entities.VirtualTag
|
db.VirtualTags.Add(new Configuration.Entities.VirtualTag
|
||||||
{
|
{
|
||||||
VirtualTagId = "vtag-speed",
|
VirtualTagId = "vtag-speed",
|
||||||
EquipmentId = "eq-1",
|
EquipmentId = equipmentId,
|
||||||
Name = "speed",
|
Name = "speed",
|
||||||
DataType = "Float",
|
DataType = "Float",
|
||||||
ScriptId = "s-1",
|
ScriptId = "s-1",
|
||||||
@@ -91,7 +96,46 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
|||||||
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
||||||
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
|
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
|
||||||
reply.Message.ShouldNotBeNull();
|
reply.Message.ShouldNotBeNull();
|
||||||
reply.Message.ShouldContain("collide"); // the rule's message text
|
reply.Message.ShouldContain("EquipmentSignalNameCollision"); // the rule's error code
|
||||||
|
reply.Message.ShouldContain("collide"); // the rule's message text
|
||||||
|
|
||||||
|
using var verify = dbFactory.CreateDbContext();
|
||||||
|
verify.Deployments.Count().ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the full gate rejects a config whose Equipment carries a NON-canonical
|
||||||
|
/// EquipmentId (not == <c>DraftValidator.DeriveEquipmentId(uuid)</c>): the deploy is
|
||||||
|
/// <see cref="StartDeploymentOutcome.Rejected"/> with <c>EquipmentIdNotDerived</c> in the message,
|
||||||
|
/// no coordinator dispatch, and no Deployment row. This is the rule the surgical gate used to
|
||||||
|
/// let through and the reason the full activation was probed first.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void StartDeployment_rejects_on_non_canonical_EquipmentId()
|
||||||
|
{
|
||||||
|
var dbFactory = NewInMemoryDbFactory();
|
||||||
|
using (var db = dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
db.Equipment.Add(new Configuration.Entities.Equipment
|
||||||
|
{
|
||||||
|
EquipmentUuid = Guid.NewGuid(),
|
||||||
|
EquipmentId = "EQ-operator-typed", // NOT derived from the UUID
|
||||||
|
Name = "rinser-01",
|
||||||
|
DriverInstanceId = "d",
|
||||||
|
UnsLineId = "line-a",
|
||||||
|
MachineCode = "m",
|
||||||
|
});
|
||||||
|
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("EquipmentIdNotDerived");
|
||||||
|
|
||||||
using var verify = dbFactory.CreateDbContext();
|
using var verify = dbFactory.CreateDbContext();
|
||||||
verify.Deployments.Count().ShouldBe(0);
|
verify.Deployments.Count().ShouldBe(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user