f5090fcd9e
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.
200 lines
8.7 KiB
C#
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);
|
|
}
|
|
}
|