feat(runtime): WithOtOpcUaRuntimeActors extension for driver-role node startup (F19)

Mirrors WithOtOpcUaControlPlaneSingletons for the driver role. Spawns
DriverHostActor + DbHealthProbeActor on the host's ActorSystem and
registers both under marker keys. Host's Program.cs now calls it when
the node carries the driver role, so driver-only and admin+driver
deployments both auto-bootstrap the per-node actors.

Integration test covers the registration round-trip via Microsoft.Extensions.Hosting
+ Akka.Hosting AddAkka.
This commit is contained in:
Joseph Doherty
2026-05-26 06:09:37 -04:00
parent 698709a578
commit 09d6676e1f
3 changed files with 145 additions and 4 deletions

View File

@@ -0,0 +1,85 @@
using Akka.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests;
/// <summary>
/// Verifies <c>WithOtOpcUaRuntimeActors</c> spawns <c>DriverHostActor</c> + <c>DbHealthProbeActor</c>
/// on the host's <c>ActorSystem</c> and registers both under their marker keys. This is the
/// driver-role mirror of the admin-role <c>WithOtOpcUaControlPlaneSingletons</c> bootstrap.
/// </summary>
public sealed class ServiceCollectionExtensionsTests
{
[Fact]
public async Task WithOtOpcUaRuntimeActors_spawns_driver_host_and_db_health_probe()
{
using var host = Host.CreateDefaultBuilder()
.ConfigureServices((_, services) =>
{
services.AddSingleton<IDbContextFactory<OtOpcUaConfigDbContext>>(
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N")));
services.AddSingleton<IClusterRoleInfo>(new FakeClusterRoleInfo());
services.AddAkka("otopcua-test", (ab, _) =>
{
ab.AddHocon(@"
akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
akka.remote.dot-netty.tcp.hostname = ""127.0.0.1""
akka.remote.dot-netty.tcp.port = 0
akka.cluster.seed-nodes = []
akka.cluster.roles = [""driver""]
", HoconAddMode.Prepend);
ab.WithOtOpcUaRuntimeActors();
});
})
.Build();
await host.StartAsync();
try
{
var driverHost = host.Services.GetRequiredService<IRequiredActor<DriverHostActorKey>>();
var dbHealth = host.Services.GetRequiredService<IRequiredActor<DbHealthProbeActorKey>>();
driverHost.ActorRef.ShouldNotBeNull();
dbHealth.ActorRef.ShouldNotBeNull();
driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName);
dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName);
}
finally
{
await host.StopAsync();
}
}
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName)
.Options;
return new OtOpcUaConfigDbContext(opts);
}
}
private sealed class FakeClusterRoleInfo : IClusterRoleInfo
{
public NodeId LocalNode { get; } = NodeId.Parse("test-node");
public IReadOnlySet<string> LocalRoles { get; } = new HashSet<string>(["driver"]);
public bool HasRole(string role) => LocalRoles.Contains(role);
public IReadOnlyList<NodeId> MembersWithRole(string role) => Array.Empty<NodeId>();
public NodeId? RoleLeader(string role) => null;
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged
{
add { _ = value; }
remove { _ = value; }
}
}
}