From da141497f881232aa78f7ebe0a0909074c99b767 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 08:57:16 -0400 Subject: [PATCH] feat(runtime): F7 spawn lifecycle + F20 ShouldStub gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...-akka-hosting-alignment-plan.md.tasks.json | 4 +- .../IDriverFactory.cs | 35 ++++ .../Hosting/DriverFactoryRegistryAdapter.cs | 28 +++ .../Drivers/DeploymentArtifact.cs | 78 +++++++ .../Drivers/DriverHostActor.cs | 157 +++++++++++++- .../Drivers/DriverSpawnPlan.cs | 67 ++++++ .../ServiceCollectionExtensions.cs | 8 +- .../Drivers/DeploymentArtifactTests.cs | 93 +++++++++ .../Drivers/DriverHostActorReconcileTests.cs | 192 ++++++++++++++++++ .../Drivers/DriverSpawnPlannerTests.cs | 118 +++++++++++ 10 files changed, 768 insertions(+), 12 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json index aa3d125..704f36b 100644 --- a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json +++ b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json @@ -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": "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 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": "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."}, @@ -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": "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": "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": "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."} ] diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs new file mode 100644 index 0000000..af14ab0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Abstraction over the process-wide driver registry. Runtime consumes this instead of +/// DriverFactoryRegistry directly so the Runtime project doesn't pull in +/// ZB.MOM.WW.OtOpcUa.Core (which would drag in Polly + driver hosting). The fused +/// Host binds a DriverFactoryRegistryAdapter after every Driver.*.Register() +/// extension has run. +/// +public interface IDriverFactory +{ + /// + /// Return a new for the given , or + /// null when no factory is registered for that type (missing assembly, typo, etc.). + /// The DriverHostActor logs + skips the row rather than failing the whole apply. + /// + IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson); + + /// Driver-type names this factory can materialise. Mostly for diagnostics + logs. + IReadOnlyCollection SupportedTypes { get; } +} + +/// +/// Returns null from every 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. +/// +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 SupportedTypes { get; } = Array.Empty(); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs new file mode 100644 index 0000000..9bd490c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Hosting; + +/// +/// Adapts the existing (v1 surface, still the +/// concrete singleton every driver assembly registers itself against) to the v2 +/// abstraction consumed by Runtime. The fused Host binds +/// this in DI once each Driver.*.Register(registry) call has completed. +/// +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 SupportedTypes => _registry.RegisteredTypes; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs new file mode 100644 index 0000000..853e7c7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -0,0 +1,78 @@ +using System.Text.Json; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// Minimal driver-side view of the deployment artifact emitted by +/// ConfigComposer.SnapshotAndFlattenAsync. The artifact JSON is the full snapshot — +/// for driver spawning we only need the DriverInstances array. Reading just the +/// subset keeps allocations cheap on every deploy. +/// +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, + }; + + /// + /// 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". + /// + public static IReadOnlyList ParseDriverInstances(ReadOnlySpan blob) + { + if (blob.IsEmpty) return Array.Empty(); + + try + { + using var doc = JsonDocument.Parse(blob.ToArray()); + if (!doc.RootElement.TryGetProperty("DriverInstances", out var arr) + || arr.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var result = new List(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(); + } + } + + 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 ?? "{}"); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 759ffa5..cffa90e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -9,6 +9,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; using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; @@ -38,11 +39,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly IDbContextFactory _dbFactory; private readonly CommonsNodeId _localNode; private readonly IActorRef? _coordinatorOverride; + private readonly IDriverFactory _driverFactory; + private readonly IReadOnlySet _localRoles; private readonly ILoggingAdapter _log = Context.GetLogger(); private RevisionHash? _currentRevision; private DeploymentId? _applyingDeploymentId; + private readonly Dictionary _children = new(StringComparer.Ordinal); + + private sealed record ChildEntry(IActorRef Actor, string DriverType, string LastConfigJson, bool Stubbed); + public ITimerScheduler Timers { get; set; } = null!; public sealed class RetryConfigDbConnection @@ -54,17 +61,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers public static Props Props( IDbContextFactory dbFactory, CommonsNodeId localNode, - IActorRef? coordinator = null) => - Akka.Actor.Props.Create(() => new DriverHostActor(dbFactory, localNode, coordinator)); + IActorRef? coordinator = null, + IDriverFactory? driverFactory = null, + IReadOnlySet? localRoles = null) => + Akka.Actor.Props.Create(() => new DriverHostActor(dbFactory, localNode, coordinator, driverFactory, localRoles)); public DriverHostActor( IDbContextFactory dbFactory, CommonsNodeId localNode, - IActorRef? coordinator) + IActorRef? coordinator, + IDriverFactory? driverFactory = null, + IReadOnlySet? localRoles = null) { _dbFactory = dbFactory; _localNode = localNode; _coordinatorOverride = coordinator; + _driverFactory = driverFactory ?? NullDriverFactory.Instance; + _localRoles = localRoles ?? new HashSet(StringComparer.Ordinal); // Default behavior is Steady — PreStart may flip to Stale or replay an orphan apply. Become(Steady); @@ -172,12 +185,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private void HandleGetDiagnostics(GetDiagnostics msg) { - // Driver-instance children aren't spawned yet (F7); the snapshot reports an empty driver - // list. CurrentRevision is real — it's what the host believes is its applied revision. + var drivers = _children + .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( NodeId: _localNode, CurrentRevision: _currentRevision, - Drivers: Array.Empty(), + Drivers: drivers, AsOfUtc: DateTime.UtcNow); Sender.Tell(snapshot); } @@ -205,11 +225,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers try { - // Future: dispatch ApplyDelta to children, wait for acks. For Task 37/38, just no-op. + ReconcileDrivers(deploymentId); _currentRevision = revision; UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applied, failureReason: null); 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) { @@ -224,6 +245,126 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } + /// + /// Read the deployment artifact + reconcile the set of running + /// 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 can't materialise any of the requested + /// types, this is effectively a no-op. + /// + 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(); + } + 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); + } + + /// + /// Minimal placeholder driver used when no factory is registered for a driver type or when + /// returns true. + /// is started with startStubbed:true so the driver methods on this object never run. + /// + 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() { try diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs new file mode 100644 index 0000000..6dbbd08 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs @@ -0,0 +1,67 @@ +namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +/// +/// Pure diff between the currently-running driver children (keyed by +/// DriverInstance.DriverInstanceId) 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. +/// +/// Specs with no current child — create a new actor. +/// Specs whose child exists but config JSON or type differs. +/// DriverInstanceIds currently running but missing from the new artifact, or now disabled. +public sealed record DriverSpawnPlan( + IReadOnlyList ToSpawn, + IReadOnlyList ToApplyDelta, + IReadOnlyList ToStop); + +public static class DriverSpawnPlanner +{ + /// + /// Compute the spawn/delta/stop sets. Disabled entries in 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). + /// + public static DriverSpawnPlan Compute( + IReadOnlyDictionary current, + IReadOnlyList target) + { + var toSpawn = new List(); + var toDelta = new List(); + var toStop = new List(); + + var targetById = new Dictionary(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); + } +} + +/// Snapshot of one running driver child as the host sees it. Used as the diff input. +public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 8dd17ac..54b897c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; 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.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Health; @@ -29,6 +30,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services) { services.TryAddSingleton(NullAlarmHistorianSink.Instance); + services.TryAddSingleton(NullDriverFactory.Instance); return services; } @@ -54,8 +56,9 @@ public static class ServiceCollectionExtensions { var dbFactory = resolver.GetService>(); var roleInfo = resolver.GetService(); - // 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() ?? NullAlarmHistorianSink.Instance; + var driverFactory = resolver.GetService() ?? NullDriverFactory.Instance; var dbHealth = system.ActorOf( DbHealthProbeActor.Props(dbFactory), @@ -63,7 +66,8 @@ public static class ServiceCollectionExtensions registry.Register(dbHealth); var driverHost = system.ActorOf( - DriverHostActor.Props(dbFactory, roleInfo.LocalNode), + DriverHostActor.Props(dbFactory, roleInfo.LocalNode, coordinator: null, + driverFactory: driverFactory, localRoles: roleInfo.LocalRoles), DriverHostActorName); registry.Register(driverHost); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs new file mode 100644 index 0000000..a4bbd36 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs @@ -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.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"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs new file mode 100644 index 0000000..da6b3e6 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorReconcileTests.cs @@ -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 { "driver" })); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + + coordinator.ExpectMsg(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 { "driver" })); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + + coordinator.ExpectMsg(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(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 { "driver" })); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + + coordinator.ExpectMsg(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 { "driver" })); + + actor.Tell(new DispatchDeployment(d1, RevA, CorrelationId.NewId())); + coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); + AwaitAssert(() => factory.CreateCount.ShouldBe(2), duration: TimeSpan.FromSeconds(3)); + + actor.Tell(new DispatchDeployment(d2, RevB, CorrelationId.NewId())); + coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); + + actor.Tell(new GetDiagnostics(CorrelationId.NewId()), coordinator.Ref); + var snap = coordinator.ExpectMsg(TimeSpan.FromSeconds(2)); + snap.Drivers.Count.ShouldBe(1); + } + + private static DeploymentId SeedDeploymentWithDrivers( + IDbContextFactory 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 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; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs new file mode 100644 index 0000000..56d765c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs @@ -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(); + 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 + { + ["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 + { + ["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 + { + ["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 + { + ["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(); + 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 + { + ["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(); + } +}