334 lines
14 KiB
C#
334 lines
14 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
|
|
{
|
|
/// <summary>Verifies that starting a deployment inserts a row and dispatches to the coordinator.</summary>
|
|
[Fact]
|
|
public void StartDeployment_inserts_deployment_and_dispatches_to_coordinator()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
var coordinator = CreateTestProbe("coord");
|
|
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
|
|
|
|
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
|
|
|
|
var dispatch = coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
|
dispatch.DeploymentId.Value.ShouldNotBe(Guid.Empty);
|
|
dispatch.RevisionHash.Value.Length.ShouldBe(64);
|
|
|
|
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
|
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
|
reply.DeploymentId.ShouldBe(dispatch.DeploymentId);
|
|
reply.RevisionHash.ShouldBe(dispatch.RevisionHash);
|
|
|
|
using var db = dbFactory.CreateDbContext();
|
|
var row = db.Deployments.Single();
|
|
row.Status.ShouldBe(DeploymentStatus.Dispatching);
|
|
row.CreatedBy.ShouldBe("joe");
|
|
row.ArtifactBlob.Length.ShouldBeGreaterThan(0);
|
|
|
|
db.ConfigEdits.Count().ShouldBe(1);
|
|
db.ConfigEdits.Single().EntityType.ShouldBe("Deployment");
|
|
}
|
|
|
|
/// <summary>Verifies the full DraftValidator gate (reject on ANY error): 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. The colliding equipment uses a canonical EquipmentId so the rejection is
|
|
/// attributable to the collision rule, not to EquipmentIdNotDerived.</summary>
|
|
[Fact]
|
|
public void StartDeployment_rejects_on_Tag_VirtualTag_NodeId_collision()
|
|
{
|
|
var uuid = Guid.NewGuid();
|
|
var equipmentId = Configuration.Validation.DraftValidator.DeriveEquipmentId(uuid);
|
|
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
db.Equipment.Add(new Configuration.Entities.Equipment
|
|
{
|
|
EquipmentUuid = uuid,
|
|
EquipmentId = equipmentId,
|
|
Name = "eq",
|
|
DriverInstanceId = "d",
|
|
UnsLineId = "line-a",
|
|
MachineCode = "m",
|
|
});
|
|
db.Tags.Add(new Configuration.Entities.Tag
|
|
{
|
|
TagId = "tag-speed",
|
|
DriverInstanceId = "d",
|
|
EquipmentId = equipmentId,
|
|
Name = "speed",
|
|
DataType = "Float",
|
|
AccessLevel = TagAccessLevel.Read,
|
|
TagConfig = "{}",
|
|
});
|
|
db.VirtualTags.Add(new Configuration.Entities.VirtualTag
|
|
{
|
|
VirtualTagId = "vtag-speed",
|
|
EquipmentId = equipmentId,
|
|
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("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();
|
|
verify.Deployments.Count().ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Verifies that a DriverInstance whose NamespaceId lives in a different cluster
|
|
/// triggers <c>BadCrossClusterNamespaceBinding</c> and routes to the Rejected branch through
|
|
/// the actor — no coordinator dispatch, no Deployment row.
|
|
/// Seeded: Namespace in cluster "MAIN" + DriverInstance in cluster "SITE-A" referencing that
|
|
/// namespace. NamespaceKind.Equipment + DriverType "ModbusTcp" satisfies the compat rule so
|
|
/// only the cross-cluster rule fires.</summary>
|
|
[Fact]
|
|
public void StartDeployment_rejects_on_cross_cluster_namespace_binding()
|
|
{
|
|
const string nsId = "ns-main-equipment";
|
|
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
db.Namespaces.Add(new Configuration.Entities.Namespace
|
|
{
|
|
NamespaceId = nsId,
|
|
ClusterId = "MAIN",
|
|
Kind = Configuration.Enums.NamespaceKind.Equipment,
|
|
NamespaceUri = "urn:zb:main:equipment",
|
|
});
|
|
db.DriverInstances.Add(new Configuration.Entities.DriverInstance
|
|
{
|
|
DriverInstanceId = "drv-site-a-01",
|
|
ClusterId = "SITE-A",
|
|
NamespaceId = nsId, // cross-cluster: drv is SITE-A, ns is MAIN
|
|
Name = "site-a-modbus",
|
|
DriverType = "ModbusTcp", // compatible with Equipment ns — no compat error
|
|
DriverConfig = "{}",
|
|
});
|
|
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("BadCrossClusterNamespaceBinding");
|
|
|
|
using var verify = dbFactory.CreateDbContext();
|
|
verify.Deployments.Count().ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>Verifies the warn-only compile-cost advisory: seeding N distinct non-passthrough
|
|
/// (genuinely-compiled) Script rows yields an <see cref="StartDeploymentOutcome.Accepted"/>
|
|
/// deploy whose <see cref="StartDeploymentResult.Message"/> carries a compile-cost advisory.
|
|
/// The guardrail NEVER rejects — it only surfaces the estimated RSS pressure to the operator.</summary>
|
|
[Fact]
|
|
public void StartDeployment_warns_when_many_scripts_will_compile()
|
|
{
|
|
const int n = 10;
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
db.Scripts.Add(new Configuration.Entities.Script
|
|
{
|
|
ScriptId = $"s-{i}",
|
|
Name = $"script-{i}",
|
|
SourceCode = $"return (int)ctx.GetTag(\"a\").Value + {i};", // distinct, non-passthrough
|
|
SourceHash = $"hash-{i}",
|
|
});
|
|
}
|
|
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()));
|
|
|
|
// Still dispatches — the advisory is non-blocking.
|
|
coordinator.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
|
|
|
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
|
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
|
reply.Message.ShouldNotBeNull();
|
|
reply.Message.ShouldContain("will compile");
|
|
reply.Message.ShouldContain($"{n} script"); // the distinct compiled count
|
|
|
|
using var verify = dbFactory.CreateDbContext();
|
|
verify.Deployments.Count().ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that passthrough "mirror" scripts (<c>return ctx.GetTag("X").Value;</c>)
|
|
/// compile to ~nothing and therefore do NOT count toward the compile-cost advisory: a deploy
|
|
/// of only passthrough scripts is <see cref="StartDeploymentOutcome.Accepted"/> with no advisory.</summary>
|
|
[Fact]
|
|
public void StartDeployment_passthrough_scripts_do_not_count()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
db.Scripts.Add(new Configuration.Entities.Script
|
|
{
|
|
ScriptId = $"mirror-{i}",
|
|
Name = $"mirror-{i}",
|
|
SourceCode = $"return ctx.GetTag(\"tag-{i}\").Value;", // passthrough mirror — costs ~nothing
|
|
SourceHash = $"mhash-{i}",
|
|
});
|
|
}
|
|
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.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
|
|
|
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
|
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
|
// No compiled scripts → no advisory emitted.
|
|
(reply.Message is null || !reply.Message.Contains("will compile")).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies the advisory counts DISTINCT non-passthrough sources: many rows sharing one
|
|
/// identical source collapse to a single compiled unit (the compile cache keys on source), so the
|
|
/// advisory reports 1 — not the row count.</summary>
|
|
[Fact]
|
|
public void StartDeployment_duplicate_sources_collapse_to_one_compiled_unit()
|
|
{
|
|
const string identical = "return (int)ctx.GetTag(\"a\").Value + 1;";
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
for (var i = 0; i < 7; i++)
|
|
{
|
|
db.Scripts.Add(new Configuration.Entities.Script
|
|
{
|
|
ScriptId = $"dup-{i}",
|
|
Name = $"dup-{i}",
|
|
SourceCode = identical, // same source across all rows
|
|
SourceHash = "dup-hash",
|
|
});
|
|
}
|
|
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.ExpectMsg<DispatchDeployment>(TimeSpan.FromSeconds(3));
|
|
|
|
var reply = ExpectMsg<StartDeploymentResult>(TimeSpan.FromSeconds(3));
|
|
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
|
|
reply.Message.ShouldNotBeNull();
|
|
reply.Message.ShouldContain("will compile");
|
|
reply.Message.ShouldContain("1 script(s) will compile");
|
|
}
|
|
|
|
/// <summary>Verifies that starting a deployment is refused when another is in flight.</summary>
|
|
[Fact]
|
|
public void StartDeployment_refuses_when_another_is_in_flight()
|
|
{
|
|
var dbFactory = NewInMemoryDbFactory();
|
|
// Seed an in-flight Deployment.
|
|
using (var db = dbFactory.CreateDbContext())
|
|
{
|
|
db.Deployments.Add(new Configuration.Entities.Deployment
|
|
{
|
|
RevisionHash = new string('a', 64),
|
|
Status = DeploymentStatus.Dispatching,
|
|
CreatedBy = "earlier",
|
|
});
|
|
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.AnotherDeploymentInFlight);
|
|
reply.DeploymentId.ShouldNotBeNull();
|
|
}
|
|
}
|