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; /// /// 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 Context.ActorOf 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. /// public class DeploymentManagerActorNameTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; /// Creates a temporary LocalDb-backed site store per test. public DeploymentManagerActorNameTests() { _localDb = TestLocalDb.CreateTemp("dm-actor-name-test"); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService(NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } /// 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.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(TimeSpan.FromSeconds(20)); } private IActorRef ResolveChild(IActorRef dm, string name) => Sys.ActorSelection(dm.Path / name).ResolveOne(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); /// /// The regression the finding asks for: deploying "a b" fails with a readable reason and /// does NOT stop the instances already running on the site. /// /// A task that represents the asynchronous operation. [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")); } /// A script whose canonical name Akka refuses is a readable gate rejection, not an S6 init failure. /// A task that represents the asynchronous operation. [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")); } /// /// Enable bypasses the deploy gate, so a row stored before names were checked reaches /// CreateInstanceActor directly — the refused name must come back as a failed enable. /// /// A task that represents the asynchronous operation. [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(TimeSpan.FromSeconds(10)); Assert.False(response.Success); Assert.Contains("a b", response.ErrorMessage); watcher.ExpectNoMsg(TimeSpan.FromSeconds(1)); } /// A stored bad-name row at boot is skipped; its siblings start and stay up. /// A task that represents the asynchronous operation. [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); } /// /// 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. /// [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.Instance))); instance.Tell(new ScriptCallRequest("Good", null, 0, "corr-1")); var result = ExpectMsg(TimeSpan.FromSeconds(15)); Assert.True(result.Success, result.ErrorMessage); } /// Lane A L3: a failed enable reverts the is_enabled flag it had already committed. /// A task that represents the asynchronous operation. [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(TimeSpan.FromSeconds(10)).Success); await AwaitAssertAsync(async () => Assert.False((await _storage.GetDeployedConfigMetadataAsync("a b"))!.IsEnabled), TimeSpan.FromSeconds(10)); } /// Lane A L2: a boot-batch skip is surfaced as an Error site event. /// A task that represents the asynchronous operation. [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)); } }