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:
Joseph Doherty
2026-05-26 08:57:16 -04:00
parent 9892ceae9a
commit da141497f8
10 changed files with 768 additions and 12 deletions

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}