feat(controlplane): ServiceLevelCalculator + ControlPlane.Tests harness
This commit is contained in:
@@ -63,6 +63,7 @@
|
||||
<Project Path="tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Server/">
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj" />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Akka.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
|
||||
public readonly record struct NodeHealthInputs(
|
||||
MemberStatus MemberState,
|
||||
bool DbReachable,
|
||||
bool OpcUaProbeOk,
|
||||
bool Stale,
|
||||
bool IsDriverRoleLeader);
|
||||
|
||||
/// <summary>
|
||||
/// Pure ServiceLevel computation per design §6. Output range 0–255, where higher = "more
|
||||
/// authoritative." The OPC UA SDK exposes this as the node's <c>ServiceLevel</c> Variable and
|
||||
/// redundant clients use it to pick which server to subscribe to.
|
||||
///
|
||||
/// Tiering:
|
||||
/// - Member not Up/Joining: 0 (cluster cannot trust this node).
|
||||
/// - DB reachable + OPC UA probe ok + not stale: 240 (full service).
|
||||
/// - Stale config (DB reachable or not, OPC UA probe state ignored): 100 or 200 depending on DB.
|
||||
/// - +10 bonus when this node holds the role-leader lease for the "driver" role.
|
||||
/// </summary>
|
||||
public static class ServiceLevelCalculator
|
||||
{
|
||||
public static byte Compute(NodeHealthInputs h)
|
||||
{
|
||||
if (h.MemberState is not (MemberStatus.Up or MemberStatus.Joining))
|
||||
return 0;
|
||||
|
||||
var basis = (h.DbReachable, h.OpcUaProbeOk, h.Stale) switch
|
||||
{
|
||||
(true, true, false) => 240,
|
||||
(true, _, true) => 200,
|
||||
(false, _, true) => 100,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
return (byte)Math.Clamp(basis + (h.IsDriverRoleLeader ? 10 : 0), 0, 255);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.Hosting"/>
|
||||
<PackageReference Include="Akka.Cluster"/>
|
||||
<PackageReference Include="Akka.Cluster.Hosting"/>
|
||||
<PackageReference Include="Akka.Cluster.Tools"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
|
||||
/// <summary>
|
||||
/// Akka TestKit fixture for ControlPlane actor tests. Provides:
|
||||
/// - A test ActorSystem (xunit2 TestKit) wired with PubSub/Cluster extensions.
|
||||
/// - A fresh in-memory <see cref="OtOpcUaConfigDbContext"/> per harness instance.
|
||||
/// - An <see cref="IDbContextFactory{TContext}"/> the actors can hold.
|
||||
///
|
||||
/// One harness per test fact — InMemory provider gives strong isolation when the database
|
||||
/// name is unique (random Guid).
|
||||
/// </summary>
|
||||
public abstract class ControlPlaneActorTestBase : TestKit
|
||||
{
|
||||
protected static string AkkaTestHocon => @"
|
||||
akka {
|
||||
loglevel = ""WARNING""
|
||||
actor {
|
||||
provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
|
||||
}
|
||||
remote.dot-netty.tcp {
|
||||
hostname = ""127.0.0.1""
|
||||
port = 0
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = []
|
||||
roles = [""admin""]
|
||||
min-nr-of-members = 1
|
||||
run-coordinated-shutdown-when-down = off
|
||||
}
|
||||
}";
|
||||
|
||||
protected ControlPlaneActorTestBase() : base(AkkaTestHocon) { }
|
||||
|
||||
protected static IDbContextFactory<OtOpcUaConfigDbContext> NewInMemoryDbFactory(string? dbName = null)
|
||||
{
|
||||
dbName ??= Guid.NewGuid().ToString("N");
|
||||
return new InMemoryConfigDbFactory(dbName);
|
||||
}
|
||||
|
||||
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Akka.Cluster;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
public sealed class ServiceLevelCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(MemberStatus.Down)]
|
||||
[InlineData(MemberStatus.Removed)]
|
||||
[InlineData(MemberStatus.Exiting)]
|
||||
[InlineData(MemberStatus.Leaving)]
|
||||
public void NotUp_returns_zero(MemberStatus status)
|
||||
{
|
||||
var sl = ServiceLevelCalculator.Compute(new(status,
|
||||
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true));
|
||||
sl.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fully_healthy_non_leader_returns_240()
|
||||
{
|
||||
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
||||
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false));
|
||||
sl.ShouldBe((byte)240);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fully_healthy_role_leader_returns_250()
|
||||
{
|
||||
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
||||
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true));
|
||||
sl.ShouldBe((byte)250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Db_reachable_but_stale_returns_200()
|
||||
{
|
||||
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
||||
DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverRoleLeader: false));
|
||||
sl.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Db_unreachable_and_stale_returns_100()
|
||||
{
|
||||
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
||||
DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverRoleLeader: false));
|
||||
sl.ShouldBe((byte)100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opcua_probe_fail_when_not_stale_returns_zero()
|
||||
{
|
||||
// (DbReachable=true, OpcUaProbeOk=false, Stale=false) falls through to the catch-all 0.
|
||||
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
|
||||
DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverRoleLeader: false));
|
||||
sl.ShouldBe((byte)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Joining_member_is_treated_like_Up_for_grading()
|
||||
{
|
||||
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining,
|
||||
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false));
|
||||
sl.ShouldBe((byte)240);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.ControlPlane.Tests</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Akka.TestKit.Xunit2 ties this project to xunit v2 (TestKit isn't ported to v3 yet). -->
|
||||
<PackageReference Include="xunit"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user