Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorNameTests.cs
T
Joseph Doherty 6f6534ac7e fix(site-runtime): artifact-apply watchdog + queued-apply failure on stop, boot-skip site event, enable revert, batch Ask slack (review 2026-10-21 lane A L1-L4)
L1: an artifact apply holding the slot past 10 min (ctor-overridable) logs an
Error site event naming it without releasing the slot; PostStop answers
queued applies Failed. L2: a boot-batch instance whose actor cannot be created
emits an Error site event and leaves the deployed set. L3: a failed enable
reverts the committed is_enabled (generation-bumped both ends). L4: the routed
SetAttributes batch Ask is the 30 s per-write bound plus 10 s mailbox slack.
2026-10-22 09:10:26 -04:00

234 lines
10 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Review 2026-10-21 01/SR-C1: an instance (or script/alarm/native-source) name that is not a
/// legal Akka path element used to make <c>Context.ActorOf</c> throw on the Deployment Manager
/// singleton's own thread. The default Restart directive then stopped EVERY Instance Actor on the
/// site and dropped all in-flight deploy bookkeeping, while the deployer's Ask hung to timeout.
/// </summary>
public class DeploymentManagerActorNameTests : TestKit, IDisposable
{
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
public DeploymentManagerActorNameTests()
{
_localDb = TestLocalDb.CreateTemp("dm-actor-name-test");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
private IActorRef CreateBootedDeploymentManager(FakeSiteEventLogger? siteLog = null)
{
var health = new DeploymentManagerStartupProbe();
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions { InstanceSweepIntervalSeconds = 0, StartupBatchDelayMs = 10 },
NullLogger<DeploymentManagerActor>.Instance,
dclManager: null,
healthCollector: health,
serviceProvider: siteLog == null ? null : new SingleServiceProvider(siteLog),
loggerFactory: null,
configFetcher: null)));
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25));
return dm;
}
private static string ConfigJson(string instanceName, string? scriptName = null) =>
JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
Scripts = scriptName == null
? []
: [new ResolvedScript { CanonicalName = scriptName, Code = "return 1;" }]
});
private DeploymentStatusResponse Deploy(IActorRef dm, string instanceName, string? scriptName = null)
{
dm.Tell(new DeployInstanceCommand(
$"dep-{Guid.NewGuid():N}", instanceName, "rev-1", ConfigJson(instanceName, scriptName),
"tester", DateTimeOffset.UtcNow));
return ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(20));
}
private IActorRef ResolveChild(IActorRef dm, string name) =>
Sys.ActorSelection(dm.Path / name).ResolveOne(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
/// <summary>
/// The regression the finding asks for: deploying <c>"a b"</c> fails with a readable reason and
/// does NOT stop the instances already running on the site.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deploy_InvalidInstanceName_FailsCleanly_AndLeavesOtherInstancesRunning()
{
var dm = CreateBootedDeploymentManager();
Assert.Equal(DeploymentStatus.Success, Deploy(dm, "Good1").Status);
var good = ResolveChild(dm, "Good1");
var watcher = CreateTestProbe();
watcher.Watch(good);
var rejected = Deploy(dm, "a b");
Assert.Equal(DeploymentStatus.Failed, rejected.Status);
Assert.Contains("a b", rejected.ErrorMessage);
Assert.Contains("runtime actor name", rejected.ErrorMessage);
watcher.ExpectNoMsg(TimeSpan.FromSeconds(1));
// The manager is still the same live incarnation and keeps serving deploys.
Assert.Equal(DeploymentStatus.Success, Deploy(dm, "Good2").Status);
Assert.Null(await _storage.GetDeployedConfigAsync("a b"));
}
/// <summary>A script whose canonical name Akka refuses is a readable gate rejection, not an S6 init failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deploy_InvalidScriptName_IsRejectedByTheGateWithTheName()
{
var dm = CreateBootedDeploymentManager();
var rejected = Deploy(dm, "Pump1", scriptName: "My Script");
Assert.Equal(DeploymentStatus.Failed, rejected.Status);
Assert.Contains("My Script", rejected.ErrorMessage);
Assert.Null(await _storage.GetDeployedConfigAsync("Pump1"));
}
/// <summary>
/// Enable bypasses the deploy gate, so a row stored before names were checked reaches
/// <c>CreateInstanceActor</c> directly — the refused name must come back as a failed enable.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Enable_StoredRowWithInvalidName_FailsWithoutRestartingTheManager()
{
await _storage.StoreDeployedConfigAsync("a b", ConfigJson("a b"), "d0", "r0", isEnabled: false);
var dm = CreateBootedDeploymentManager();
Assert.Equal(DeploymentStatus.Success, Deploy(dm, "Good1").Status);
var watcher = CreateTestProbe();
watcher.Watch(ResolveChild(dm, "Good1"));
dm.Tell(new EnableInstanceCommand("cmd-1", "a b", DateTimeOffset.UtcNow));
var response = ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(10));
Assert.False(response.Success);
Assert.Contains("a b", response.ErrorMessage);
watcher.ExpectNoMsg(TimeSpan.FromSeconds(1));
}
/// <summary>A stored bad-name row at boot is skipped; its siblings start and stay up.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Boot_StoredRowWithInvalidName_DoesNotTakeDownItsSiblings()
{
await _storage.StoreDeployedConfigAsync("a b", ConfigJson("a b"), "d0", "r0", isEnabled: true);
await _storage.StoreDeployedConfigAsync("Good1", ConfigJson("Good1"), "d1", "r1", isEnabled: true);
var dm = CreateBootedDeploymentManager();
IActorRef? good = null;
AwaitAssert(() => good = ResolveChild(dm, "Good1"), TimeSpan.FromSeconds(10));
var watcher = CreateTestProbe();
watcher.Watch(good!);
watcher.ExpectNoMsg(TimeSpan.FromSeconds(1));
Assert.Equal(DeploymentStatus.Success, Deploy(dm, "Good2").Status);
}
/// <summary>
/// The Instance Actor half: a stored config whose script name Akka refuses skips that one
/// script (logged) instead of failing the whole instance's PreStart.
/// </summary>
[Fact]
public void InstanceActor_InvalidChildName_SkipsThatChild_AndKeepsTheRest()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "Pump1",
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
Scripts =
[
new ResolvedScript { CanonicalName = "bad name", Code = "return 1;" },
new ResolvedScript { CanonicalName = "Good", Code = "return 7;" }
]
};
var instance = ActorOf(Props.Create(() => new InstanceActor(
"Pump1", JsonSerializer.Serialize(config), _storage, _compilationService,
_sharedScriptLibrary, null, new SiteRuntimeOptions(), NullLogger<InstanceActor>.Instance)));
instance.Tell(new ScriptCallRequest("Good", null, 0, "corr-1"));
var result = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(15));
Assert.True(result.Success, result.ErrorMessage);
}
/// <summary>Lane A L3: a failed enable reverts the is_enabled flag it had already committed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Enable_FailedCreate_RevertsIsEnabled()
{
await _storage.StoreDeployedConfigAsync("a b", ConfigJson("a b"), "d0", "r0", isEnabled: false);
var dm = CreateBootedDeploymentManager();
dm.Tell(new EnableInstanceCommand("cmd-1", "a b", DateTimeOffset.UtcNow));
Assert.False(ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(10)).Success);
await AwaitAssertAsync(async () =>
Assert.False((await _storage.GetDeployedConfigMetadataAsync("a b"))!.IsEnabled),
TimeSpan.FromSeconds(10));
}
/// <summary>Lane A L2: a boot-batch skip is surfaced as an Error site event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Boot_SkippedInvalidName_EmitsASiteEvent()
{
await _storage.StoreDeployedConfigAsync("a b", ConfigJson("a b"), "d0", "r0", isEnabled: true);
var siteLog = new FakeSiteEventLogger();
CreateBootedDeploymentManager(siteLog);
await AwaitAssertAsync(() =>
{
Assert.Contains(siteLog.OfType("deployment"), r =>
r.Severity == "Error" && r.InstanceId == "a b" &&
r.Message.Contains("could not be started at boot", StringComparison.Ordinal));
return Task.CompletedTask;
}, TimeSpan.FromSeconds(10));
}
}