Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorNameTests.cs
T
Joseph Doherty f5090fcd9e fix(site-runtime): reject invalid runtime actor names at the site gate and never let ActorOf escalate to the Deployment Manager (review 2026-10-21 01/SR-C1)
An instance unique name Akka refuses as a path element (space, '#', '/',
non-ASCII) made Context.ActorOf throw on the singleton's own thread; the
default Restart stopped every Instance Actor on the site. DeployCompileValidator
now checks the instance + script/alarm/native-source actor names with
ActorPath.IsValidPathElement (the Commons ActorNameRules mirror is stricter
than Akka for " ( ), pinned by a parity sweep), CreateInstanceActor catches
InvalidActorNameException and every caller reports it, and the Instance Actor
skips a refused child name like a compile failure.
2026-10-22 09:10:26 -04:00

200 lines
8.7 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()
{
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: null,
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);
}
}