feat(adminops): IDriverProbe + TestDriverConnect actor handler

- IDriverProbe abstraction in Core.Abstractions; one impl per driver
  type, resolved by DriverType string. Phase 7.3 + 7.4 add concrete
  probes for the 9 supported driver types.
- TestDriverConnect / TestDriverConnectResult messages.
- AdminOperationsActor.HandleTestDriverConnectAsync looks up the probe
  by DriverType, runs it with a [1,60]s clamped timeout, and returns
  success/latency or failure/message. Probes that throw or time out
  surface as soft failures.
This commit is contained in:
Joseph Doherty
2026-05-28 10:44:00 -04:00
parent 4584612a1a
commit f3f328c25c
6 changed files with 119 additions and 6 deletions
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
/// <summary>
/// AdminUI → AdminOperationsActor request: probe one driver type's connection using
/// the supplied JSON config. Routed through <c>IAdminOperationsClient</c>; reply is
/// <see cref="TestDriverConnectResult"/>.
/// </summary>
/// <param name="DriverType">Must match an installed <c>IDriverProbe.DriverType</c>.</param>
/// <param name="ConfigJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="TimeoutSeconds">Per-probe timeout; server clamps to [1, 60].</param>
/// <param name="CorrelationId">Round-trip correlation token.</param>
public sealed record TestDriverConnect(
string DriverType,
string ConfigJson,
int TimeoutSeconds,
Guid CorrelationId);
/// <summary>Reply for <see cref="TestDriverConnect"/>.</summary>
/// <param name="Ok">True iff the probe succeeded.</param>
/// <param name="Message">Failure reason; null on success.</param>
/// <param name="LatencyMs">Round-trip latency in milliseconds; null on failure or timeout.</param>
/// <param name="CorrelationId">Echoes the request's correlation token.</param>
public sealed record TestDriverConnectResult(
bool Ok,
string? Message,
double? LatencyMs,
Guid CorrelationId);
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Test-connect probe for one driver type. Implementations deserialize a driver-config
/// JSON, attempt a cheap connection (TCP open, OPC UA session, gRPC ping — whatever the
/// driver's native protocol supports), and report success/failure with latency. Probes
/// MUST NOT mutate any persistent state; the AdminUI invokes them against transient
/// config from the typed form, NOT against the persisted DriverInstance row.
/// </summary>
public interface IDriverProbe
{
/// <summary>DriverInstance.DriverType string this probe handles. Used for DI lookup.</summary>
string DriverType { get; }
/// <summary>
/// Run the probe with the supplied config + timeout. Honour <paramref name="ct"/> for
/// timeout cancellation. Never throw on connection failure; instead return a result
/// with <c>Ok = false</c> + a message.
/// </summary>
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
/// <summary>Outcome of a single <see cref="IDriverProbe.ProbeAsync"/> call.</summary>
/// <param name="Ok">True iff the probe reached its target and the handshake succeeded.</param>
/// <param name="Message">Human-readable status; null on success.</param>
/// <param name="Latency">Wall-clock duration of the successful probe; null on failure.</param>
public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency);
@@ -7,6 +7,7 @@ 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;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
@@ -19,28 +20,35 @@ public sealed class AdminOperationsActor : ReceiveActor
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly IActorRef _coordinator;
private readonly IReadOnlyDictionary<string, IDriverProbe> _probesByType;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates actor props for the admin operations actor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
/// <returns>Props configured to create an AdminOperationsActor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator));
IActorRef coordinator,
IEnumerable<IDriverProbe> probes) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes));
/// <summary>Initializes a new instance of the AdminOperationsActor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
public AdminOperationsActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator)
IActorRef coordinator,
IEnumerable<IDriverProbe> probes)
{
_dbFactory = dbFactory;
_coordinator = coordinator;
_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
ReceiveAsync<StartDeployment>(HandleStartDeploymentAsync);
ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync);
}
private async Task HandleStartDeploymentAsync(StartDeployment msg)
@@ -112,4 +120,51 @@ public sealed class AdminOperationsActor : ReceiveActor
msg.CorrelationId));
}
}
private async Task HandleTestDriverConnectAsync(TestDriverConnect msg)
{
var replyTo = Sender;
if (!_probesByType.TryGetValue(msg.DriverType, out var probe))
{
replyTo.Tell(new TestDriverConnectResult(
false,
$"No probe registered for driver type '{msg.DriverType}'.",
null,
msg.CorrelationId));
return;
}
var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60);
var timeout = TimeSpan.FromSeconds(clampedSec);
using var cts = new CancellationTokenSource(timeout);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var result = await probe.ProbeAsync(msg.ConfigJson, timeout, cts.Token);
sw.Stop();
replyTo.Tell(new TestDriverConnectResult(
result.Ok,
result.Message,
result.Ok ? sw.Elapsed.TotalMilliseconds : (double?)null,
msg.CorrelationId));
}
catch (OperationCanceledException)
{
replyTo.Tell(new TestDriverConnectResult(
false,
$"Probe timed out after {clampedSec}s.",
null,
msg.CorrelationId));
}
catch (Exception ex)
{
_log.Error(ex, "Probe for {DriverType} threw", msg.DriverType);
replyTo.Tell(new TestDriverConnectResult(
false,
ex.Message,
null,
msg.CorrelationId));
}
}
}
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane;
@@ -59,7 +60,8 @@ public static class ServiceCollectionExtensions
{
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
var coordinator = registry.Get<ConfigPublishCoordinatorKey>();
return AdminOperationsActor.Props(dbFactory, coordinator);
var probes = resolver.GetService<IEnumerable<IDriverProbe>>() ?? Enumerable.Empty<IDriverProbe>();
return AdminOperationsActor.Props(dbFactory, coordinator, probes);
},
singletonOptions);
@@ -21,6 +21,7 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -8,6 +8,7 @@ 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;
@@ -19,7 +20,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref));
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
@@ -60,7 +61,7 @@ public sealed class AdminOperationsActorTests : ControlPlaneActorTestBase
}
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref));
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));