298 lines
13 KiB
C#
298 lines
13 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
|
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.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
public sealed class DriverHostActorReconcileTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
|
|
|
/// <summary>Verifies that applying a deployment with driver instances spawns one child per enabled row.</summary>
|
|
[Fact]
|
|
public void Apply_with_driver_instances_in_artifact_spawns_one_child_per_enabled_row()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new CountingDriverFactory("Modbus");
|
|
var deploymentId = SeedDeploymentWithDrivers(db, RevA,
|
|
("DI-1", "Modbus", "{}", true),
|
|
("DI-2", "Modbus", "{}", true),
|
|
("DI-3", "Modbus", "{}", false)); // disabled — not spawned
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Verifies that applying a deployment with unsupported driver type falls back to stub.</summary>
|
|
[Fact]
|
|
public void Apply_with_unsupported_driver_type_falls_back_to_stub()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
// Factory only supports "Modbus" — the Galaxy row should boot stubbed.
|
|
var factory = new CountingDriverFactory("Modbus");
|
|
var deploymentId = SeedDeploymentWithDrivers(db, RevA,
|
|
("DI-galaxy", "Galaxy", "{}", true));
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
|
|
// No real driver was constructed — stubbing took over.
|
|
factory.CreateCount.ShouldBe(0);
|
|
|
|
// GetDiagnostics should still report the (stubbed) child.
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
|
|
snap.Drivers.Count.ShouldBe(1);
|
|
snap.Drivers[0].State.ShouldBe("Stubbed");
|
|
}
|
|
|
|
/// <summary>Verifies that Galaxy driver on non-Windows is stubbed by ShouldStub check.</summary>
|
|
[Fact]
|
|
public void Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check()
|
|
{
|
|
// Even if the factory could create it, ShouldStub('Galaxy', ...) returns true on macOS/Linux —
|
|
// the factory should never be called.
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new CountingDriverFactory("Galaxy");
|
|
var deploymentId = SeedDeploymentWithDrivers(db, RevA,
|
|
("DI-galaxy", "Galaxy", "{}", true));
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
factory.CreateCount.ShouldBe(1);
|
|
}
|
|
else
|
|
{
|
|
factory.CreateCount.ShouldBe(0); // ShouldStub forced the stub path
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a second apply with removed driver stops the child.</summary>
|
|
[Fact]
|
|
public void Second_apply_with_removed_driver_stops_the_child()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new CountingDriverFactory("Modbus");
|
|
var d1 = SeedDeploymentWithDrivers(db, RevA, ("DI-1", "Modbus", "{}", true), ("DI-2", "Modbus", "{}", true));
|
|
var d2 = SeedDeploymentWithDrivers(db, RevB, ("DI-1", "Modbus", "{}", true));
|
|
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(d1, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));
|
|
AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(new DispatchDeployment(d2, RevB, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5));
|
|
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
|
|
snap.Drivers.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies per-ClusterId scoping at the actor level: a 2-cluster artifact (MAIN + SITE-A,
|
|
/// one driver each) dispatched to a node whose ClusterNode row puts it in SITE-A spawns ONLY
|
|
/// the SITE-A driver — and the node still reaches Applied (the ack fires unconditionally even
|
|
/// when a node's cluster slice is empty).
|
|
/// </summary>
|
|
[Fact]
|
|
public void DriverHostActor_spawns_only_its_clusters_drivers()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new CountingDriverFactory("Modbus");
|
|
// Both drivers are Modbus so the factory could create either — scoping, not type support,
|
|
// must be what excludes the MAIN driver.
|
|
var deploymentId = SeedMultiClusterDeployment(db, RevA,
|
|
("main-modbus", "Modbus", "{}", true, "MAIN"),
|
|
("sa-modbus", "Modbus", "{}", true, "SITE-A"));
|
|
|
|
// This node belongs to SITE-A per the Nodes (ClusterNode) rows.
|
|
var siteANode = NodeId.Parse("site-a-1:4053");
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, siteANode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
|
|
// The node still reaches Applied even though it hosts only its own cluster's slice.
|
|
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
|
|
// Only the SITE-A driver was constructed — the MAIN driver was filtered out by scoping.
|
|
AwaitAssert(() => factory.CreateCount.ShouldBe(1), duration: TimeSpan.FromSeconds(3));
|
|
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref);
|
|
var snap = coordinator.ExpectMsg<Commons.Interfaces.NodeDiagnosticsSnapshot>(TimeSpan.FromSeconds(2));
|
|
snap.Drivers.Count.ShouldBe(1);
|
|
snap.Drivers[0].Name.ShouldBe("sa-modbus");
|
|
}
|
|
|
|
private static DeploymentId SeedMultiClusterDeployment(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db,
|
|
RevisionHash rev,
|
|
params (string Id, string Type, string Config, bool Enabled, string ClusterId)[] drivers)
|
|
{
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
// >1 cluster + matching Nodes rows triggers ScopeTo (single-cluster would resolve to None).
|
|
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
|
Nodes = new[]
|
|
{
|
|
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
|
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
|
},
|
|
DriverInstances = drivers.Select(d => new
|
|
{
|
|
DriverInstanceRowId = Guid.NewGuid(),
|
|
DriverInstanceId = d.Id,
|
|
Name = d.Id,
|
|
DriverType = d.Type,
|
|
Enabled = d.Enabled,
|
|
DriverConfig = d.Config,
|
|
ClusterId = d.ClusterId,
|
|
}).ToArray(),
|
|
});
|
|
|
|
var id = DeploymentId.NewId();
|
|
using var ctx = db.CreateDbContext();
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = rev.Value,
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = artifact,
|
|
});
|
|
ctx.SaveChanges();
|
|
return id;
|
|
}
|
|
|
|
private static DeploymentId SeedDeploymentWithDrivers(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db,
|
|
RevisionHash rev,
|
|
params (string Id, string Type, string Config, bool Enabled)[] drivers)
|
|
{
|
|
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
DriverInstances = drivers.Select(d => new
|
|
{
|
|
DriverInstanceRowId = Guid.NewGuid(),
|
|
DriverInstanceId = d.Id,
|
|
Name = d.Id,
|
|
DriverType = d.Type,
|
|
Enabled = d.Enabled,
|
|
DriverConfig = d.Config,
|
|
}).ToArray(),
|
|
});
|
|
|
|
var id = DeploymentId.NewId();
|
|
using var ctx = db.CreateDbContext();
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = rev.Value,
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = artifact,
|
|
});
|
|
ctx.SaveChanges();
|
|
return id;
|
|
}
|
|
|
|
/// <summary>Test double for IDriverFactory that counts driver creation attempts.</summary>
|
|
private sealed class CountingDriverFactory : IDriverFactory
|
|
{
|
|
private readonly string _supportedType;
|
|
/// <summary>Gets the number of times TryCreate was called and returned a driver.</summary>
|
|
public int CreateCount;
|
|
/// <summary>Initializes a new instance with the specified supported driver type.</summary>
|
|
/// <param name="supportedType">The driver type this factory supports.</param>
|
|
public CountingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
|
|
|
/// <summary>Attempts to create a driver if the type is supported.</summary>
|
|
/// <param name="driverType">The driver type to create.</param>
|
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
|
{
|
|
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
|
|
Interlocked.Increment(ref CreateCount);
|
|
return new TestDriver(driverInstanceId, driverType);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
|
}
|
|
|
|
/// <summary>Test double for IDriver with minimal implementation.</summary>
|
|
private sealed class TestDriver : IDriver
|
|
{
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId { get; }
|
|
/// <inheritdoc />
|
|
public string DriverType { get; }
|
|
/// <summary>Initializes a new test driver with the specified ID and type.</summary>
|
|
/// <param name="id">The driver instance ID.</param>
|
|
/// <param name="type">The driver type.</param>
|
|
public TestDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
|
|
/// <inheritdoc />
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
|
|
/// <inheritdoc />
|
|
public long GetMemoryFootprint() => 0;
|
|
/// <inheritdoc />
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
}
|
|
}
|