feat(runtime): F7 spawn lifecycle + F20 ShouldStub gate
DriverHostActor.ApplyAndAck now reads the deployment artifact and reconciles its set of DriverInstanceActor children — spawn the missing, ApplyDelta to those with changed config, stop the removed/disabled. The diff lives in pure DriverSpawnPlanner so it can be unit-tested without an ActorSystem. Adds IDriverFactory in Core.Abstractions (consumed by Runtime) + DriverFactoryRegistryAdapter in Core.Hosting that wraps the existing v1 DriverFactoryRegistry — Runtime stays decoupled from Polly/Serilog, the Host wires the adapter once driver assemblies have registered. ShouldStub(type, roles) is now actually called on every spawn — Galaxy + Wonderware-Historian boot stubbed on macOS/Linux or whenever the host carries the dev role. Missing factory ⇒ stub fallback, never a crash. Tests: 24 → 34 in Runtime (+10): - DriverSpawnPlannerTests x7 (diff cases, type change ⇒ stop+respawn) - DeploymentArtifactTests x5 (empty/malformed/missing fields tolerant) - DriverHostActorReconcileTests x4 (spawn count, stub fallback, ShouldStub gate, second-apply stops the removed) All 6 v2 test suites green: 120 tests passing. Closes F20 (ShouldStub wired). F7 marked partial — subscription publishing + write path still stubbed in DriverInstanceActor itself.
This commit is contained in:
@@ -81,7 +81,7 @@
|
|||||||
{"id": "F4", "subject": "Follow-up: Harden AuditWriterActor.WrapDetails JSON synthesis with System.Text.Json", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F3"], "blockedBy": [], "commit": "f57f61d", "deviation": "Moot — F3 deleted WrapDetails entirely (EventId/CorrelationId now live in dedicated columns).", "origin": "Self-review of Task 33 — WrapDetails uses string concat; malformed caller DetailsJson would produce invalid JSON and trip the CK_ConfigAuditLog_DetailsJson_IsJson constraint, killing the entire flush batch. Discard this task if F3 lands first (F3 removes WrapDetails entirely)."},
|
{"id": "F4", "subject": "Follow-up: Harden AuditWriterActor.WrapDetails JSON synthesis with System.Text.Json", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F3"], "blockedBy": [], "commit": "f57f61d", "deviation": "Moot — F3 deleted WrapDetails entirely (EventId/CorrelationId now live in dedicated columns).", "origin": "Self-review of Task 33 — WrapDetails uses string concat; malformed caller DetailsJson would produce invalid JSON and trip the CK_ConfigAuditLog_DetailsJson_IsJson constraint, killing the entire flush batch. Discard this task if F3 lands first (F3 removes WrapDetails entirely)."},
|
||||||
{"id": "F5", "subject": "Follow-up: ConfigPublishCoordinator multi-node happy-path test", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "5cfbe8b", "deviation": "Delivered by Task 59 — DeployHappyPathTests.StartDeployment_seals_after_both_nodes_apply exercises the exact 'dispatch to N driver nodes, all ack, seals' flow via the real 2-node TwoNodeClusterHarness rather than a multi-system TestKit. Cleaner because it tests the production code path end-to-end.", "origin": "Self-review of Task 30 — single-ActorSystem TestKit can't simulate the plan's 'dispatch to N driver nodes, all ack, seals' happy path because DiscoverDriverNodes() needs real cluster membership. Add a multi-system test (two ActorSystems joined into one cluster, driver-role on the second)."},
|
{"id": "F5", "subject": "Follow-up: ConfigPublishCoordinator multi-node happy-path test", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "5cfbe8b", "deviation": "Delivered by Task 59 — DeployHappyPathTests.StartDeployment_seals_after_both_nodes_apply exercises the exact 'dispatch to N driver nodes, all ack, seals' flow via the real 2-node TwoNodeClusterHarness rather than a multi-system TestKit. Cleaner because it tests the production code path end-to-end.", "origin": "Self-review of Task 30 — single-ActorSystem TestKit can't simulate the plan's 'dispatch to N driver nodes, all ack, seals' happy path because DiscoverDriverNodes() needs real cluster membership. Add a multi-system test (two ActorSystems joined into one cluster, driver-role on the second)."},
|
||||||
{"id": "F6", "subject": "Follow-up: RedundancyStateActor publisher abstraction so tests don't need DPS bootstrap", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "commit": "dfc143c", "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action<object> broadcast so tests can replace it with a probe; un-skip both tests."},
|
{"id": "F6", "subject": "Follow-up: RedundancyStateActor publisher abstraction so tests don't need DPS bootstrap", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "commit": "dfc143c", "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action<object> broadcast so tests can replace it with a probe; un-skip both tests."},
|
||||||
{"id": "F7", "subject": "Follow-up: DriverInstanceActor full engine wiring (subscriptions, writes, ApplyDelta diff)", "status": "pending", "classification": "standard", "estMinutes": 45, "parallelizableWith": [], "blockedBy": [44], "origin": "Self-review of Task 41 — subscription publishing, ApplyDelta diffing, bad-quality-on-disconnect, write path, and supervisor backoff are stubbed. Wire after OpcUaPublishActor lands."},
|
{"id": "F7", "subject": "Follow-up: DriverInstanceActor full engine wiring (subscriptions, writes, ApplyDelta diff)", "status": "partial", "classification": "standard", "estMinutes": 45, "parallelizableWith": [], "blockedBy": [44], "origin": "Self-review of Task 41 — subscription publishing, ApplyDelta diffing, bad-quality-on-disconnect, write path, and supervisor backoff are stubbed. Wire after OpcUaPublishActor lands.", "shipped": "Spawn lifecycle in DriverHostActor: artifact parsing, DriverSpawnPlanner pure-diff (spawn/delta/stop), IDriverFactory abstraction in Core.Abstractions with NullDriverFactory + DriverFactoryRegistryAdapter, ApplyDelta forwarded to children. Subscription publishing + write path still stubbed — split into F7-sub (subscribe + write)."},
|
||||||
{"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers."},
|
{"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers."},
|
||||||
{"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 43 — AlarmConditionService not called; PreRestart persistence to ScriptedAlarmState DB not wired; HistorianAdapter rows not emitted."},
|
{"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 43 — AlarmConditionService not called; PreRestart persistence to ScriptedAlarmState DB not wired; HistorianAdapter rows not emitted."},
|
||||||
{"id": "F10", "subject": "Follow-up: OpcUaPublishActor SDK integration (address-space writes + ServiceLevel + RebuildAddressSpace)", "status": "pending", "classification": "high-risk", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [47], "origin": "Self-review of Task 44 — SDK calls stubbed; counters only. Wire after Phase 7 OpcUaServer extraction."},
|
{"id": "F10", "subject": "Follow-up: OpcUaPublishActor SDK integration (address-space writes + ServiceLevel + RebuildAddressSpace)", "status": "pending", "classification": "high-risk", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [47], "origin": "Self-review of Task 44 — SDK calls stubbed; counters only. Wire after Phase 7 OpcUaServer extraction."},
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
{"id": "F17", "subject": "Follow-up: FleetDiagnosticsClient real Akka ActorSelection round-trip (GetDiagnosticsRequest)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "8f32b89", "origin": "Self-review of Task 51 — client returns an empty snapshot stub. Add GetDiagnosticsRequest contract + DriverHostActor handler + real Ask/Reply."},
|
{"id": "F17", "subject": "Follow-up: FleetDiagnosticsClient real Akka ActorSelection round-trip (GetDiagnosticsRequest)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "8f32b89", "origin": "Self-review of Task 51 — client returns an empty snapshot stub. Add GetDiagnosticsRequest contract + DriverHostActor handler + real Ask/Reply."},
|
||||||
{"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "commit": "b266f63", "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."},
|
{"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "commit": "b266f63", "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."},
|
||||||
{"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "completed", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "09d6676", "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."},
|
{"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "completed", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "09d6676", "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."},
|
||||||
{"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children."},
|
{"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children.", "shipped": "DriverHostActor.SpawnChild now calls DriverInstanceActor.ShouldStub(type, _localRoles) and routes Windows-only driver types to the stub path on non-Windows / dev-role hosts. Verified by DriverHostActorReconcileTests.Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check."},
|
||||||
{"id": "F21", "subject": "Follow-up: docker-compose.yml for Host.IntegrationTests (real SQL Server + OpenLDAP)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "b0a2bb0", "deviationNotes": "Stack shipped (SQL on 14331, OpenLDAP on 3894). HarnessMode reads OTOPCUA_HARNESS_USE_SQL=1 / USE_LDAP=1 from env; SQL mode uses per-harness unique DB via EnsureCreated. Compose itself not local-validated — DESKTOP-6JL3KKO has no Docker per CLAUDE.md; CI on Linux will exercise the real path. The xunit test-trait split was punted — env vars are simpler and cover the same use case (one suite, two modes, no test-class duplication).", "origin": "Deviation from Task 58 — TwoNodeClusterHarness uses EF InMemoryDatabase + StubLdapAuthService. For Mac-friendly local runs against real SQL constraints + LDAP, add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml (SQL Server + OpenLDAP), wire EF SqlServer provider behind an env var (OTOPCUA_HARNESS_USE_SQL=1), and add a test trait so CI can run both modes."},
|
{"id": "F21", "subject": "Follow-up: docker-compose.yml for Host.IntegrationTests (real SQL Server + OpenLDAP)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "b0a2bb0", "deviationNotes": "Stack shipped (SQL on 14331, OpenLDAP on 3894). HarnessMode reads OTOPCUA_HARNESS_USE_SQL=1 / USE_LDAP=1 from env; SQL mode uses per-harness unique DB via EnsureCreated. Compose itself not local-validated — DESKTOP-6JL3KKO has no Docker per CLAUDE.md; CI on Linux will exercise the real path. The xunit test-trait split was punted — env vars are simpler and cover the same use case (one suite, two modes, no test-class duplication).", "origin": "Deviation from Task 58 — TwoNodeClusterHarness uses EF InMemoryDatabase + StubLdapAuthService. For Mac-friendly local runs against real SQL constraints + LDAP, add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml (SQL Server + OpenLDAP), wire EF SqlServer provider behind an env var (OTOPCUA_HARNESS_USE_SQL=1), and add a test trait so CI can run both modes."},
|
||||||
{"id": "F22", "subject": "Follow-up: failover scenario integration tests (kill-mid-apply, split-brain, restart-during-deploy)", "status": "completed", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "commit": "cd5540c", "deviationNotes": "Shipped 3 scenarios on the existing 2-node harness: stop-shrinks, restart-rejoins-same-port, deploy-with-one-node-down. Split-brain via simulated partition deferred — Akka.Hosting + xunit don't expose transport-level interference cleanly. The graceful-shutdown + rejoin path is what production actually exercises; ungraceful kill-mid-apply non-deterministic under SBR's 15s stable-after.", "origin": "Deviation from Task 59 — happy-path + idempotency landed but design §8 cases 3-7 need controlled node-down primitives on TwoNodeClusterHarness (StopNodeAsync, RestartNodeAsync, PartitionBetweenAsync). Add those + 5 scenario tests."}
|
{"id": "F22", "subject": "Follow-up: failover scenario integration tests (kill-mid-apply, split-brain, restart-during-deploy)", "status": "completed", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "commit": "cd5540c", "deviationNotes": "Shipped 3 scenarios on the existing 2-node harness: stop-shrinks, restart-rejoins-same-port, deploy-with-one-node-down. Split-brain via simulated partition deferred — Akka.Hosting + xunit don't expose transport-level interference cleanly. The graceful-shutdown + rejoin path is what production actually exercises; ungraceful kill-mid-apply non-deterministic under SBR's 15s stable-after.", "origin": "Deviation from Task 59 — happy-path + idempotency landed but design §8 cases 3-7 need controlled node-down primitives on TwoNodeClusterHarness (StopNodeAsync, RestartNodeAsync, PartitionBetweenAsync). Add those + 5 scenario tests."}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over the process-wide driver registry. Runtime consumes this instead of
|
||||||
|
/// <c>DriverFactoryRegistry</c> directly so the Runtime project doesn't pull in
|
||||||
|
/// <c>ZB.MOM.WW.OtOpcUa.Core</c> (which would drag in Polly + driver hosting). The fused
|
||||||
|
/// Host binds a <c>DriverFactoryRegistryAdapter</c> after every <c>Driver.*.Register()</c>
|
||||||
|
/// extension has run.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDriverFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Return a new <see cref="IDriver"/> for the given <paramref name="driverType"/>, or
|
||||||
|
/// <c>null</c> when no factory is registered for that type (missing assembly, typo, etc.).
|
||||||
|
/// The DriverHostActor logs + skips the row rather than failing the whole apply.
|
||||||
|
/// </summary>
|
||||||
|
IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson);
|
||||||
|
|
||||||
|
/// <summary>Driver-type names this factory can materialise. Mostly for diagnostics + logs.</summary>
|
||||||
|
IReadOnlyCollection<string> SupportedTypes { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <c>null</c> from every <see cref="IDriverFactory.TryCreate"/> call. Bound when the
|
||||||
|
/// fused Host hasn't registered any concrete driver assemblies yet (Mac dev path, smoke
|
||||||
|
/// tests). DriverHostActor sees zero supported types and treats the deployment as a no-op.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullDriverFactory : IDriverFactory
|
||||||
|
{
|
||||||
|
public static readonly NullDriverFactory Instance = new();
|
||||||
|
private NullDriverFactory() { }
|
||||||
|
|
||||||
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapts the existing <see cref="DriverFactoryRegistry"/> (v1 surface, still the
|
||||||
|
/// concrete singleton every driver assembly registers itself against) to the v2
|
||||||
|
/// <see cref="IDriverFactory"/> abstraction consumed by Runtime. The fused Host binds
|
||||||
|
/// this in DI once each <c>Driver.*.Register(registry)</c> call has completed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DriverFactoryRegistryAdapter : IDriverFactory
|
||||||
|
{
|
||||||
|
private readonly DriverFactoryRegistry _registry;
|
||||||
|
|
||||||
|
public DriverFactoryRegistryAdapter(DriverFactoryRegistry registry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
_registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
var factory = _registry.TryGet(driverType);
|
||||||
|
return factory?.Invoke(driverInstanceId, driverConfigJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal driver-side view of the deployment artifact emitted by
|
||||||
|
/// <c>ConfigComposer.SnapshotAndFlattenAsync</c>. The artifact JSON is the full snapshot —
|
||||||
|
/// for driver spawning we only need the <c>DriverInstances</c> array. Reading just the
|
||||||
|
/// subset keeps allocations cheap on every deploy.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DriverInstanceSpec(
|
||||||
|
Guid DriverInstanceRowId,
|
||||||
|
string DriverInstanceId,
|
||||||
|
string Name,
|
||||||
|
string DriverType,
|
||||||
|
bool Enabled,
|
||||||
|
string DriverConfig);
|
||||||
|
|
||||||
|
public static class DeploymentArtifact
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
|
||||||
|
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
|
||||||
|
{
|
||||||
|
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||||
|
if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr)
|
||||||
|
|| arr.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return Array.Empty<DriverInstanceSpec>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
||||||
|
foreach (var el in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
var spec = TryReadSpec(el);
|
||||||
|
if (spec is not null) result.Add(spec);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Array.Empty<DriverInstanceSpec>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DriverInstanceSpec? TryReadSpec(JsonElement el)
|
||||||
|
{
|
||||||
|
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
|
||||||
|
&& rowEl.TryGetGuid(out var rid) ? rid : Guid.Empty;
|
||||||
|
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||||
|
var type = el.TryGetProperty("DriverType", out var typeEl) ? typeEl.GetString() : null;
|
||||||
|
var enabled = !el.TryGetProperty("Enabled", out var enEl) || enEl.GetBoolean();
|
||||||
|
var config = el.TryGetProperty("DriverConfig", out var cfgEl) ? cfgEl.GetString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return null;
|
||||||
|
|
||||||
|
return new DriverInstanceSpec(
|
||||||
|
DriverInstanceRowId: rowId,
|
||||||
|
DriverInstanceId: id!,
|
||||||
|
Name: name ?? id!,
|
||||||
|
DriverType: type!,
|
||||||
|
Enabled: enabled,
|
||||||
|
DriverConfig: config ?? "{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
|
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
@@ -38,11 +39,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||||
private readonly CommonsNodeId _localNode;
|
private readonly CommonsNodeId _localNode;
|
||||||
private readonly IActorRef? _coordinatorOverride;
|
private readonly IActorRef? _coordinatorOverride;
|
||||||
|
private readonly IDriverFactory _driverFactory;
|
||||||
|
private readonly IReadOnlySet<string> _localRoles;
|
||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
|
||||||
private RevisionHash? _currentRevision;
|
private RevisionHash? _currentRevision;
|
||||||
private DeploymentId? _applyingDeploymentId;
|
private DeploymentId? _applyingDeploymentId;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private sealed record ChildEntry(IActorRef Actor, string DriverType, string LastConfigJson, bool Stubbed);
|
||||||
|
|
||||||
public ITimerScheduler Timers { get; set; } = null!;
|
public ITimerScheduler Timers { get; set; } = null!;
|
||||||
|
|
||||||
public sealed class RetryConfigDbConnection
|
public sealed class RetryConfigDbConnection
|
||||||
@@ -54,17 +61,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
public static Props Props(
|
public static Props Props(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||||
CommonsNodeId localNode,
|
CommonsNodeId localNode,
|
||||||
IActorRef? coordinator = null) =>
|
IActorRef? coordinator = null,
|
||||||
Akka.Actor.Props.Create(() => new DriverHostActor(dbFactory, localNode, coordinator));
|
IDriverFactory? driverFactory = null,
|
||||||
|
IReadOnlySet<string>? localRoles = null) =>
|
||||||
|
Akka.Actor.Props.Create(() => new DriverHostActor(dbFactory, localNode, coordinator, driverFactory, localRoles));
|
||||||
|
|
||||||
public DriverHostActor(
|
public DriverHostActor(
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||||
CommonsNodeId localNode,
|
CommonsNodeId localNode,
|
||||||
IActorRef? coordinator)
|
IActorRef? coordinator,
|
||||||
|
IDriverFactory? driverFactory = null,
|
||||||
|
IReadOnlySet<string>? localRoles = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_localNode = localNode;
|
_localNode = localNode;
|
||||||
_coordinatorOverride = coordinator;
|
_coordinatorOverride = coordinator;
|
||||||
|
_driverFactory = driverFactory ?? NullDriverFactory.Instance;
|
||||||
|
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
|
// Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply.
|
||||||
Become(Steady);
|
Become(Steady);
|
||||||
@@ -172,12 +185,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
private void HandleGetDiagnostics(GetDiagnostics msg)
|
private void HandleGetDiagnostics(GetDiagnostics msg)
|
||||||
{
|
{
|
||||||
// Driver-instance children aren't spawned yet (F7); the snapshot reports an empty driver
|
var drivers = _children
|
||||||
// list. CurrentRevision is real — it's what the host believes is its applied revision.
|
.Select(kv => new DriverInstanceDiagnostics(
|
||||||
|
DriverInstanceId: Guid.Empty,
|
||||||
|
Name: kv.Key,
|
||||||
|
State: kv.Value.Stubbed ? "Stubbed" : "Spawned",
|
||||||
|
ConnectedDevices: 0,
|
||||||
|
FaultedDevices: 0,
|
||||||
|
LastChangeUtc: DateTime.UtcNow))
|
||||||
|
.ToArray();
|
||||||
var snapshot = new NodeDiagnosticsSnapshot(
|
var snapshot = new NodeDiagnosticsSnapshot(
|
||||||
NodeId: _localNode,
|
NodeId: _localNode,
|
||||||
CurrentRevision: _currentRevision,
|
CurrentRevision: _currentRevision,
|
||||||
Drivers: Array.Empty<DriverInstanceDiagnostics>(),
|
Drivers: drivers,
|
||||||
AsOfUtc: DateTime.UtcNow);
|
AsOfUtc: DateTime.UtcNow);
|
||||||
Sender.Tell(snapshot);
|
Sender.Tell(snapshot);
|
||||||
}
|
}
|
||||||
@@ -205,11 +225,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Future: dispatch ApplyDelta to children, wait for acks. For Task 37/38, just no-op.
|
ReconcileDrivers(deploymentId);
|
||||||
_currentRevision = revision;
|
_currentRevision = revision;
|
||||||
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applied, failureReason: null);
|
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applied, failureReason: null);
|
||||||
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
|
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
|
||||||
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev})", _localNode, deploymentId, revision);
|
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})",
|
||||||
|
_localNode, deploymentId, revision, _children.Count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -224,6 +245,126 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
|
||||||
|
/// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
|
||||||
|
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) or the
|
||||||
|
/// configured <see cref="IDriverFactory"/> can't materialise any of the requested
|
||||||
|
/// types, this is effectively a no-op.
|
||||||
|
/// </summary>
|
||||||
|
private void ReconcileDrivers(DeploymentId deploymentId)
|
||||||
|
{
|
||||||
|
byte[] blob;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var db = _dbFactory.CreateDbContext();
|
||||||
|
blob = db.Deployments.AsNoTracking()
|
||||||
|
.Where(d => d.DeploymentId == deploymentId.Value)
|
||||||
|
.Select(d => d.ArtifactBlob)
|
||||||
|
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Warning(ex, "DriverHost {Node}: failed to load artifact for {Id}; skipping reconcile",
|
||||||
|
_localNode, deploymentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var specs = DeploymentArtifact.ParseDriverInstances(blob);
|
||||||
|
var snapshots = _children.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => new DriverChildSnapshot(kv.Value.DriverType, kv.Value.LastConfigJson),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
|
||||||
|
|
||||||
|
foreach (var id in plan.ToStop) StopChild(id);
|
||||||
|
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
||||||
|
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SpawnChild(DriverInstanceSpec spec)
|
||||||
|
{
|
||||||
|
var stub = DriverInstanceActor.ShouldStub(spec.DriverType, _localRoles);
|
||||||
|
IDriver? driver = null;
|
||||||
|
if (!stub)
|
||||||
|
{
|
||||||
|
try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing",
|
||||||
|
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||||
|
}
|
||||||
|
if (driver is null)
|
||||||
|
{
|
||||||
|
_log.Warning(
|
||||||
|
"DriverHost {Node}: no factory for driver type {Type} (instance {Id}); falling back to stub",
|
||||||
|
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||||
|
stub = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IActorRef child;
|
||||||
|
if (stub)
|
||||||
|
{
|
||||||
|
child = Context.ActorOf(
|
||||||
|
DriverInstanceActor.Props(new StubbedDriver(spec.DriverInstanceId, spec.DriverType),
|
||||||
|
reconnectInterval: null, startStubbed: true),
|
||||||
|
ActorNameFor(spec.DriverInstanceId));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
child = Context.ActorOf(
|
||||||
|
DriverInstanceActor.Props(driver!),
|
||||||
|
ActorNameFor(spec.DriverInstanceId));
|
||||||
|
child.Tell(new DriverInstanceActor.InitializeRequested(spec.DriverConfig));
|
||||||
|
}
|
||||||
|
|
||||||
|
_children[spec.DriverInstanceId] = new ChildEntry(child, spec.DriverType, spec.DriverConfig, stub);
|
||||||
|
_log.Info("DriverHost {Node}: spawned {Type} driver {Id} (stub={Stub})",
|
||||||
|
_localNode, spec.DriverType, spec.DriverInstanceId, stub);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||||
|
{
|
||||||
|
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||||
|
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId()));
|
||||||
|
_children[spec.DriverInstanceId] = entry with { LastConfigJson = spec.DriverConfig };
|
||||||
|
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopChild(string driverInstanceId)
|
||||||
|
{
|
||||||
|
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||||
|
Context.Stop(entry.Actor);
|
||||||
|
_children.Remove(driverInstanceId);
|
||||||
|
_log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ActorNameFor(string driverInstanceId)
|
||||||
|
{
|
||||||
|
// Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively.
|
||||||
|
var chars = driverInstanceId.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '_').ToArray();
|
||||||
|
return "drv-" + new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal placeholder driver used when no factory is registered for a driver type or when
|
||||||
|
/// <see cref="DriverInstanceActor.ShouldStub"/> returns true. <see cref="DriverInstanceActor"/>
|
||||||
|
/// is started with <c>startStubbed:true</c> so the driver methods on this object never run.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StubbedDriver : IDriver
|
||||||
|
{
|
||||||
|
public string DriverInstanceId { get; }
|
||||||
|
public string DriverType { get; }
|
||||||
|
public StubbedDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
private void TryRecoverFromStale()
|
private void TryRecoverFromStale()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure diff between the currently-running driver children (keyed by
|
||||||
|
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
|
||||||
|
/// deployment artifact. The DriverHostActor consumes the three lists and calls
|
||||||
|
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
|
||||||
|
/// <param name="ToApplyDelta">Specs whose child exists but config JSON or type differs.</param>
|
||||||
|
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||||
|
public sealed record DriverSpawnPlan(
|
||||||
|
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||||
|
IReadOnlyList<DriverInstanceSpec> ToApplyDelta,
|
||||||
|
IReadOnlyList<string> ToStop);
|
||||||
|
|
||||||
|
public static class DriverSpawnPlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Compute the spawn/delta/stop sets. Disabled entries in <paramref name="target"/> are
|
||||||
|
/// treated as "not desired here": if a child exists for the id it goes into ToStop,
|
||||||
|
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
|
||||||
|
/// </summary>
|
||||||
|
public static DriverSpawnPlan Compute(
|
||||||
|
IReadOnlyDictionary<string, DriverChildSnapshot> current,
|
||||||
|
IReadOnlyList<DriverInstanceSpec> target)
|
||||||
|
{
|
||||||
|
var toSpawn = new List<DriverInstanceSpec>();
|
||||||
|
var toDelta = new List<DriverInstanceSpec>();
|
||||||
|
var toStop = new List<string>();
|
||||||
|
|
||||||
|
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
|
||||||
|
foreach (var spec in target) targetById[spec.DriverInstanceId] = spec;
|
||||||
|
|
||||||
|
foreach (var (id, snap) in current)
|
||||||
|
{
|
||||||
|
if (!targetById.TryGetValue(id, out var spec) || !spec.Enabled)
|
||||||
|
{
|
||||||
|
toStop.Add(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Driver type changes can't be reinitialized in-place (factory-bound) — stop + respawn.
|
||||||
|
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
toStop.Add(id);
|
||||||
|
toSpawn.Add(spec);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
toDelta.Add(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (id, spec) in targetById)
|
||||||
|
{
|
||||||
|
if (!spec.Enabled) continue;
|
||||||
|
if (current.ContainsKey(id)) continue;
|
||||||
|
toSpawn.Add(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DriverSpawnPlan(toSpawn, toDelta, toStop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Snapshot of one running driver child as the host sees it. Used as the diff input.</summary>
|
||||||
|
public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson);
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||||
@@ -29,6 +30,7 @@ public static class ServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
|
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||||
|
services.TryAddSingleton<IDriverFactory>(NullDriverFactory.Instance);
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +56,9 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||||
var roleInfo = resolver.GetService<IClusterRoleInfo>();
|
var roleInfo = resolver.GetService<IClusterRoleInfo>();
|
||||||
// Fallback to NullAlarmHistorianSink if AddOtOpcUaRuntime wasn't called (e.g., test harnesses).
|
// Fallback to Null* if AddOtOpcUaRuntime wasn't called (e.g., test harnesses).
|
||||||
var historianSink = resolver.GetService<IAlarmHistorianSink>() ?? NullAlarmHistorianSink.Instance;
|
var historianSink = resolver.GetService<IAlarmHistorianSink>() ?? NullAlarmHistorianSink.Instance;
|
||||||
|
var driverFactory = resolver.GetService<IDriverFactory>() ?? NullDriverFactory.Instance;
|
||||||
|
|
||||||
var dbHealth = system.ActorOf(
|
var dbHealth = system.ActorOf(
|
||||||
DbHealthProbeActor.Props(dbFactory),
|
DbHealthProbeActor.Props(dbFactory),
|
||||||
@@ -63,7 +66,8 @@ public static class ServiceCollectionExtensions
|
|||||||
registry.Register<DbHealthProbeActorKey>(dbHealth);
|
registry.Register<DbHealthProbeActorKey>(dbHealth);
|
||||||
|
|
||||||
var driverHost = system.ActorOf(
|
var driverHost = system.ActorOf(
|
||||||
DriverHostActor.Props(dbFactory, roleInfo.LocalNode),
|
DriverHostActor.Props(dbFactory, roleInfo.LocalNode, coordinator: null,
|
||||||
|
driverFactory: driverFactory, localRoles: roleInfo.LocalRoles),
|
||||||
DriverHostActorName);
|
DriverHostActorName);
|
||||||
registry.Register<DriverHostActorKey>(driverHost);
|
registry.Register<DriverHostActorKey>(driverHost);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
public sealed class DeploymentArtifactTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Empty_blob_returns_empty_list()
|
||||||
|
{
|
||||||
|
DeploymentArtifact.ParseDriverInstances(ReadOnlySpan<byte>.Empty).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Malformed_json_returns_empty_list()
|
||||||
|
{
|
||||||
|
DeploymentArtifact.ParseDriverInstances(Encoding.UTF8.GetBytes("not json")).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_without_DriverInstances_returns_empty()
|
||||||
|
{
|
||||||
|
var blob = Encoding.UTF8.GetBytes("{\"Clusters\":[]}");
|
||||||
|
DeploymentArtifact.ParseDriverInstances(blob).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parses_driver_instances_from_composer_shaped_blob()
|
||||||
|
{
|
||||||
|
// Mirrors the shape ConfigComposer.SnapshotAndFlattenAsync emits — Pascal-case fields
|
||||||
|
// serialised directly off the EF entity.
|
||||||
|
var rowId = Guid.NewGuid();
|
||||||
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
DriverInstances = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
DriverInstanceRowId = rowId,
|
||||||
|
DriverInstanceId = "DI-modbus-1",
|
||||||
|
Name = "Modbus Line A",
|
||||||
|
DriverType = "Modbus",
|
||||||
|
Enabled = true,
|
||||||
|
DriverConfig = "{\"host\":\"127.0.0.1\"}",
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
DriverInstanceRowId = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = "DI-disabled",
|
||||||
|
Name = "Decommissioned",
|
||||||
|
DriverType = "AbCip",
|
||||||
|
Enabled = false,
|
||||||
|
DriverConfig = "{}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var specs = DeploymentArtifact.ParseDriverInstances(blob);
|
||||||
|
|
||||||
|
specs.Count.ShouldBe(2);
|
||||||
|
specs[0].DriverInstanceRowId.ShouldBe(rowId);
|
||||||
|
specs[0].DriverInstanceId.ShouldBe("DI-modbus-1");
|
||||||
|
specs[0].DriverType.ShouldBe("Modbus");
|
||||||
|
specs[0].Enabled.ShouldBeTrue();
|
||||||
|
specs[0].DriverConfig.ShouldContain("127.0.0.1");
|
||||||
|
specs[1].Enabled.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Spec_missing_required_fields_is_dropped()
|
||||||
|
{
|
||||||
|
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
DriverInstances = new object[]
|
||||||
|
{
|
||||||
|
new { Name = "no-id" },
|
||||||
|
new
|
||||||
|
{
|
||||||
|
DriverInstanceId = "DI-ok",
|
||||||
|
DriverType = "Modbus",
|
||||||
|
DriverConfig = "{}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var specs = DeploymentArtifact.ParseDriverInstances(blob);
|
||||||
|
|
||||||
|
specs.Single().DriverInstanceId.ShouldBe("DI-ok");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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));
|
||||||
|
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CountingDriverFactory : IDriverFactory
|
||||||
|
{
|
||||||
|
private readonly string _supportedType;
|
||||||
|
public int CreateCount;
|
||||||
|
public CountingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDriver : IDriver
|
||||||
|
{
|
||||||
|
public string DriverInstanceId { get; }
|
||||||
|
public string DriverType { get; }
|
||||||
|
public TestDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
public sealed class DriverSpawnPlannerTests
|
||||||
|
{
|
||||||
|
private static DriverInstanceSpec Spec(string id, string type = "Modbus", string config = "{\"host\":\"127.0.0.1\"}", bool enabled = true) =>
|
||||||
|
new(Guid.NewGuid(), id, id, type, enabled, config);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_new_drivers_go_into_ToSpawn_when_current_is_empty()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>();
|
||||||
|
var target = new[] { Spec("a"), Spec("b") };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToSpawn.Count.ShouldBe(2);
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
|
plan.ToStop.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Same_config_yields_empty_plan()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
|
{
|
||||||
|
["a"] = new("Modbus", "{\"host\":\"127.0.0.1\"}"),
|
||||||
|
};
|
||||||
|
var target = new[] { Spec("a") };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
|
plan.ToStop.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Different_config_routes_to_ApplyDelta()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
|
{
|
||||||
|
["a"] = new("Modbus", "{\"host\":\"old\"}"),
|
||||||
|
};
|
||||||
|
var target = new[] { Spec("a", config: "{\"host\":\"new\"}") };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a");
|
||||||
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
|
plan.ToStop.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Removed_driver_routes_to_ToStop()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
|
{
|
||||||
|
["a"] = new("Modbus", "{\"host\":\"127.0.0.1\"}"),
|
||||||
|
["b"] = new("Modbus", "{}"),
|
||||||
|
};
|
||||||
|
var target = new[] { Spec("a") };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToStop.ShouldBe(new[] { "b" });
|
||||||
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_driver_with_running_child_routes_to_ToStop()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
|
{
|
||||||
|
["a"] = new("Modbus", "{}"),
|
||||||
|
};
|
||||||
|
var target = new[] { Spec("a", enabled: false) };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_new_driver_is_not_spawned()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>();
|
||||||
|
var target = new[] { Spec("a", enabled: false) };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
|
plan.ToStop.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Driver_type_change_triggers_stop_plus_respawn()
|
||||||
|
{
|
||||||
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
|
{
|
||||||
|
["a"] = new("Modbus", "{}"),
|
||||||
|
};
|
||||||
|
var target = new[] { Spec("a", type: "AbCip") };
|
||||||
|
|
||||||
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
|
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user