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:
@@ -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