From f3f328c25c2be3e09b727da90ccc922b0bbf2034 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 10:44:00 -0400 Subject: [PATCH] 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. --- .../Messages/Admin/TestDriverConnect.cs | 27 ++++++++ .../IDriverProbe.cs | 27 ++++++++ .../AdminOperations/AdminOperationsActor.cs | 61 ++++++++++++++++++- .../ServiceCollectionExtensions.cs | 4 +- .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 1 + .../AdminOperationsActorTests.cs | 5 +- 6 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs new file mode 100644 index 00000000..dafdaf47 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/TestDriverConnect.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; + +/// +/// AdminUI → AdminOperationsActor request: probe one driver type's connection using +/// the supplied JSON config. Routed through IAdminOperationsClient; reply is +/// . +/// +/// Must match an installed IDriverProbe.DriverType. +/// Driver config as JSON (same shape as DriverInstance.DriverConfig). +/// Per-probe timeout; server clamps to [1, 60]. +/// Round-trip correlation token. +public sealed record TestDriverConnect( + string DriverType, + string ConfigJson, + int TimeoutSeconds, + Guid CorrelationId); + +/// Reply for . +/// True iff the probe succeeded. +/// Failure reason; null on success. +/// Round-trip latency in milliseconds; null on failure or timeout. +/// Echoes the request's correlation token. +public sealed record TestDriverConnectResult( + bool Ok, + string? Message, + double? LatencyMs, + Guid CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs new file mode 100644 index 00000000..2043f3cf --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// 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. +/// +public interface IDriverProbe +{ + /// DriverInstance.DriverType string this probe handles. Used for DI lookup. + string DriverType { get; } + + /// + /// Run the probe with the supplied config + timeout. Honour for + /// timeout cancellation. Never throw on connection failure; instead return a result + /// with Ok = false + a message. + /// + Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct); +} + +/// Outcome of a single call. +/// True iff the probe reached its target and the handshake succeeded. +/// Human-readable status; null on success. +/// Wall-clock duration of the successful probe; null on failure. +public sealed record DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index b84e1a7f..671524b3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -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 _dbFactory; private readonly IActorRef _coordinator; + private readonly IReadOnlyDictionary _probesByType; private readonly ILoggingAdapter _log = Context.GetLogger(); /// Creates actor props for the admin operations actor. /// Factory for creating config database contexts. /// Reference to the deployment coordinator actor. + /// Driver probes registered in DI; keyed by DriverType (case-insensitive). /// Props configured to create an AdminOperationsActor. public static Props Props( IDbContextFactory dbFactory, - IActorRef coordinator) => - Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator)); + IActorRef coordinator, + IEnumerable probes) => + Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes)); /// Initializes a new instance of the AdminOperationsActor. /// Factory for creating config database contexts. /// Reference to the deployment coordinator actor. + /// Driver probes registered in DI; keyed by DriverType (case-insensitive). public AdminOperationsActor( IDbContextFactory dbFactory, - IActorRef coordinator) + IActorRef coordinator, + IEnumerable probes) { _dbFactory = dbFactory; _coordinator = coordinator; + _probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase); ReceiveAsync(HandleStartDeploymentAsync); + ReceiveAsync(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)); + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs index 4e174da3..e9276742 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs @@ -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>(); var coordinator = registry.Get(); - return AdminOperationsActor.Props(dbFactory, coordinator); + var probes = resolver.GetService>() ?? Enumerable.Empty(); + return AdminOperationsActor.Props(dbFactory, coordinator, probes); }, singletonOptions); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index d30b3e25..57565199 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs index e0f4d73b..559110cb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTests.cs @@ -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())); 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())); actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));