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.
This commit is contained in:
Joseph Doherty
2026-10-22 06:24:28 -04:00
parent b5cebf9fa6
commit f5090fcd9e
8 changed files with 606 additions and 23 deletions
@@ -840,8 +840,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
for (var i = startIdx; i < endIdx; i++)
{
var config = state.Configs[i];
// CreateInstanceActor ensures this config's DCL connections (register row 85).
CreateInstanceActor(config.InstanceUniqueName, config.ConfigJson, config.RevisionHash);
// CreateInstanceActor ensures this config's DCL connections (register row 85). A
// refused actor name is logged inside and skips just that instance (01/SR-C1).
CreateInstanceActor(config.InstanceUniqueName, config.ConfigJson, config.RevisionHash, out _);
}
UpdateInstanceCounts();
@@ -1051,17 +1052,24 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// (SiteScriptCompileCache, owned by the ScriptCompilationService singleton this
// actor shares with every Instance Actor) too, and the Instance Actor's PreStart
// reuses the gate's compile (N4).
var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
//
// The gate also rejects any instance/script/alarm/native-source name that is not a legal
// Akka actor name (review 2026-10-21 01/SR-C1) — such a name used to throw out of
// Context.ActorOf on THIS actor's thread and restart the singleton with every instance.
var errors = _deployCompileValidator.Validate(
command.FlattenedConfigurationJson, command.InstanceUniqueName);
if (errors.Count > 0)
{
var instanceName = command.InstanceUniqueName;
var joined = string.Join(" | ", errors);
var nameRejected = errors.Any(DeployCompileValidator.IsActorNameError);
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy rejected — site compile validation failed (deploymentId={command.DeploymentId})",
$"Instance {instanceName} deploy rejected — site {(nameRejected ? "deploy" : "compile")} validation failed (deploymentId={command.DeploymentId})",
joined);
replyTo.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed,
"Script compilation failed on site: " + joined, DateTimeOffset.UtcNow));
(nameRejected ? "Site deploy validation failed: " : "Script compilation failed on site: ") + joined,
DateTimeOffset.UtcNow));
return;
}
@@ -1341,6 +1349,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = command.InstanceUniqueName;
// Review 2026-10-21 01/SR-C1, defence in depth behind the compile gate: never dispatch
// the static-state reset or persist a row for a name Context.ActorOf will refuse.
if (!ActorPath.IsValidPathElement(instanceName))
{
FailDeployForInvalidName(command, sender);
return;
}
// Static overrides and mirrored native alarm state are reset on redeployment per
// design decision — the new config may bind different sources, and the source snapshot
// re-seeds the mirror on (re)subscribe. Dispatched HERE, BEFORE the successor exists,
@@ -1359,9 +1375,20 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Create the Instance Actor immediately. CreateInstanceActor first ensures the DCL
// connections this config's data-sourced attributes subscribe through (register row 85).
CreateInstanceActor(
instanceName, command.FlattenedConfigurationJson, command.RevisionHash,
staticStateResetGate);
if (!CreateInstanceActor(
instanceName, command.FlattenedConfigurationJson, command.RevisionHash,
out var createError, staticStateResetGate))
{
// Unreachable while the name pre-check above holds; kept so an ActorOf refusal can
// never escape to the supervisor and restart the singleton.
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy failed (deploymentId={command.DeploymentId})", createError);
sender.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed,
createError, DateTimeOffset.UtcNow));
return;
}
if (!isRedeploy)
_deployedInstanceNames.Add(instanceName);
UpdateInstanceCounts();
@@ -1415,6 +1442,24 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}).PipeTo(Self);
}
/// <summary>
/// Replies <see cref="DeploymentStatus.Failed"/> for an instance name Akka refuses as an
/// actor name (review 2026-10-21 01/SR-C1). Nothing has been applied when this runs.
/// </summary>
private void FailDeployForInvalidName(DeployInstanceCommand command, IActorRef sender)
{
var instanceName = command.InstanceUniqueName;
var error = DescribeInvalidInstanceName(instanceName);
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} deploy rejected (deploymentId={command.DeploymentId})", error);
sender.Tell(new DeploymentStatusResponse(
command.DeploymentId, instanceName, DeploymentStatus.Failed, error, DateTimeOffset.UtcNow));
}
private static string DescribeInvalidInstanceName(string instanceName) =>
$"Instance name '{instanceName}' cannot be used as a runtime actor name on the site " +
"(spaces, '#', '/', '[', ']', '%' and non-ASCII letters are not allowed, nor a leading '$').";
/// <summary>
/// Records the persistence half of the deploy-success join. On a persistence
/// failure the optimistically-created Instance Actor is stopped and the counter
@@ -1661,9 +1706,17 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return;
}
if (!_instanceActors.ContainsKey(instanceName))
if (!_instanceActors.ContainsKey(instanceName) &&
!CreateInstanceActor(instanceName, result.Config.ConfigJson, result.Config.RevisionHash,
out var createError))
{
CreateInstanceActor(instanceName, result.Config.ConfigJson, result.Config.RevisionHash);
// Review 2026-10-21 01/SR-C1: the enable path bypasses the deploy gate, so a stored
// row whose name Akka refuses (deployed before the gate checked names) lands here.
// Report it instead of letting ActorOf restart the singleton.
LogDeploymentEvent("Error", instanceName, $"Instance {instanceName} enable failed", createError);
result.OriginalSender.Tell(new InstanceLifecycleResponse(
result.Command.CommandId, instanceName, false, createError, DateTimeOffset.UtcNow));
return;
}
UpdateInstanceCounts();
@@ -1831,7 +1884,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
var instanceName = config.InstanceUniqueName;
CreateInstanceActor(instanceName, config.ConfigJson, config.RevisionHash);
if (!CreateInstanceActor(instanceName, config.ConfigJson, config.RevisionHash, out var createError))
{
// Review 2026-10-21 01/SR-C1: surfaced, never escalated. The sweep claims one attempt
// per (instance, revision), so this is reported once rather than every tick.
LogDeploymentEvent("Error", instanceName,
$"Instance {instanceName} could not be started by {reason} self-heal", createError);
return;
}
_deployedInstanceNames.Add(instanceName);
UpdateInstanceCounts();
@@ -3462,16 +3523,25 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// the single creation funnel, so recording it here covers every path that can ever start an
/// Instance Actor.
/// </param>
/// <param name="error">
/// Why the actor could not be created, or <see langword="null"/> on success (including the
/// already-exists no-op).
/// </param>
/// <param name="staticStateResetGate">
/// The deploy path's static-override / native-alarm reset, dispatched BEFORE this call so the
/// new actor's PreStart override load can be ordered after it.
/// <see langword="null"/> on every non-deploy start (boot batch, enable, self-heal), where
/// nothing is being reset and the load runs immediately as before.
/// </param>
internal void CreateInstanceActor(
string instanceName, string configJson, string revisionHash,
/// <returns>
/// <see langword="false"/> when Akka refused the actor name (review 2026-10-21 01/SR-C1);
/// nothing was created or recorded. Callers turn that into a Failed reply / site event.
/// </returns>
internal bool CreateInstanceActor(
string instanceName, string configJson, string revisionHash, out string? error,
Task? staticStateResetGate = null)
{
error = null;
// Deferred register row 85 — an Instance Actor is useless without the DCL connection
// actors its data-sourced attributes subscribe through, so ensuring them belongs in the
// single creation funnel rather than at each caller.
@@ -3500,7 +3570,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (_instanceActors.ContainsKey(instanceName))
{
_logger.LogWarning("Instance Actor {Instance} already exists, skipping creation", instanceName);
return;
return true;
}
// Reuse the shared, host-configured logger factory
@@ -3520,7 +3590,26 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
null,
staticStateResetGate));
var actorRef = Context.ActorOf(props, instanceName);
// Review 2026-10-21 01/SR-C1: ActorOf throws InvalidActorNameException SYNCHRONOUSLY on
// this actor's thread for a name Akka refuses (space, '#', '/', non-ASCII, ...). Escaping
// the handler it would hit this singleton's own supervisor, whose default Restart stops
// EVERY Instance Actor on the site and drops all in-flight deploy bookkeeping. The deploy
// gate rejects such names up front; this catch covers the paths that bypass it (boot
// load, enable, self-heal of a row stored before the gate checked names).
IActorRef actorRef;
try
{
actorRef = Context.ActorOf(props, instanceName);
}
catch (InvalidActorNameException ex)
{
error = ActorPath.IsValidPathElement(instanceName)
? $"Instance Actor '{instanceName}' could not be created: {ex.Message}"
: DescribeInvalidInstanceName(instanceName);
_logger.LogError(ex, "Instance Actor {Instance} could not be created", instanceName);
return false;
}
_instanceActors[instanceName] = actorRef;
_runningRevisions[instanceName] = revisionHash;
// Watch every Instance Actor so an unexpected termination (init failure or
@@ -3530,6 +3619,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Context.Watch(actorRef);
_logger.LogDebug("Created Instance Actor for {Instance}", instanceName);
return true;
}
/// <summary>
@@ -1943,7 +1943,8 @@ public class InstanceActor : ReceiveActor
_healthCollector,
_serviceProvider));
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
var actorRef = TryCreateChild(props, $"script-{script.CanonicalName}", "Script", script.CanonicalName);
if (actorRef == null) continue;
_scriptActors[script.CanonicalName] = actorRef;
RouteChild(actorRef, TriggerRouting.ForScript(script.TriggerType, script.TriggerConfiguration));
}
@@ -2001,7 +2002,8 @@ public class InstanceActor : ReceiveActor
// Per-script timeout for the alarm on-trigger script.
onTriggerTimeoutSeconds));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
var actorRef = TryCreateChild(props, $"alarm-{alarm.CanonicalName}", "Alarm", alarm.CanonicalName);
if (actorRef == null) continue;
_alarmActors[alarm.CanonicalName] = actorRef;
_alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
_alarmMesReportable[alarm.CanonicalName] = alarm.IsMesReportable;
@@ -2034,7 +2036,9 @@ public class InstanceActor : ReceiveActor
nativeKind,
_serviceProvider));
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
var actorRef = TryCreateChild(
props, $"native-alarm-{nativeSource.CanonicalName}", "Native alarm source", nativeSource.CanonicalName);
if (actorRef == null) continue;
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
// Same kind handed to the NativeAlarmActor — used to stamp the
// (cosmetic) kind badge on placeholder rows for quiet bindings.
@@ -2046,6 +2050,31 @@ public class InstanceActor : ReceiveActor
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count, _nativeAlarmActors.Count);
}
/// <summary>
/// Creates one child actor, or returns <see langword="null"/> when Akka refuses its name
/// (review 2026-10-21 01/SR-C1). The site deploy gate rejects such names before anything is
/// applied, so this only fires for a config stored before the gate checked names (boot load,
/// enable, self-heal) — and there it is handled like a compile failure: the offending child is
/// logged and skipped, the rest of the instance runs. It used to throw out of
/// <see cref="PreStart"/> and fail the whole instance.
/// </summary>
private IActorRef? TryCreateChild(Props props, string actorName, string kind, string canonicalName)
{
try
{
return Context.ActorOf(props, actorName);
}
// Only a name Akka refuses OUTRIGHT is skipped. A duplicate canonical name (also an
// InvalidActorNameException, "not unique") keeps failing PreStart as it always has.
catch (InvalidActorNameException ex) when (!ActorPath.IsValidPathElement(actorName))
{
_logger.LogError(ex,
"{Kind} '{Name}' on instance '{Instance}' skipped — its name cannot be used as a runtime actor name",
kind, canonicalName, _instanceUniqueName);
return null;
}
}
/// <summary>
/// Maps a bound connection's protocol to the native alarm kind stamped on
/// emitted events. MxAccess Gateway connections yield
@@ -1,4 +1,5 @@
using System.Text.Json;
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -30,6 +31,18 @@ public sealed class DeployCompileValidator
public DeployCompileValidator(ScriptCompilationService compilationService)
=> _compilationService = compilationService;
/// <summary>
/// Prefix carried by every runtime-name error, so a caller can tell a name rejection
/// (<see cref="IsActorNameError"/>) from a compile failure when wording its reply.
/// </summary>
private const string ActorNameErrorPrefix = "Invalid runtime name: ";
/// <summary>True when <paramref name="error"/> came from the runtime-name check.</summary>
/// <param name="error">One error returned by <see cref="Validate"/>.</param>
/// <returns><see langword="true"/> for a runtime-name error.</returns>
public static bool IsActorNameError(string error) =>
error.StartsWith(ActorNameErrorPrefix, StringComparison.Ordinal);
/// <summary>
/// Validates every compilable artifact in the flattened configuration.
/// Returns an empty list when the whole configuration compiles; otherwise
@@ -37,8 +50,13 @@ public sealed class DeployCompileValidator
/// single error (this also fronts the poison-config case for startup load).
/// </summary>
/// <param name="configJson">The flattened-configuration JSON to validate.</param>
/// <param name="instanceName">
/// The instance unique name the Deployment Manager will create the Instance Actor under.
/// When supplied it is checked as an actor name along with every script/alarm/native-source
/// child name (review 2026-10-21 01/SR-C1); <see langword="null"/> checks the child names only.
/// </param>
/// <returns>A list of compile-error messages; empty if the configuration is clean.</returns>
public IReadOnlyList<string> Validate(string configJson)
public IReadOnlyList<string> Validate(string configJson, string? instanceName = null)
{
FlattenedConfiguration? config;
try
@@ -55,6 +73,10 @@ public sealed class DeployCompileValidator
var errors = new List<string>();
// Runtime names first: they are cheap, and a name Akka refuses would otherwise surface
// as an InvalidActorNameException at Context.ActorOf instead of a readable rejection.
AddActorNameErrors(errors, config, instanceName);
// Scripts: full execution-path compile against ScriptGlobals.
foreach (var script in config.Scripts)
{
@@ -97,6 +119,48 @@ public sealed class DeployCompileValidator
return errors;
}
/// <summary>
/// Review 2026-10-21 01/SR-C1: every name that becomes an Akka actor name on the site — the
/// Instance Actor itself (<c>DeploymentManagerActor.CreateInstanceActor</c>) and its
/// <c>script-</c>/<c>alarm-</c>/<c>native-alarm-</c> children
/// (<c>InstanceActor.CreateChildActors</c>) — must be a legal path element. An instance named
/// <c>"Furnace #3"</c> used to make <c>Context.ActorOf</c> throw on the Deployment Manager
/// singleton's own thread, restarting it and every Instance Actor on the site.
///
/// <para>
/// Decided with Akka's own <see cref="ActorPath.IsValidPathElement"/>, not the Commons mirror
/// <c>ActorNameRules</c>: the mirror is currently STRICTER (it refuses <c>"</c>, <c>(</c> and
/// <c>)</c>, which Akka accepts), so gating on it would reject deployments that work today.
/// </para>
/// </summary>
private static void AddActorNameErrors(
List<string> errors, FlattenedConfiguration config, string? instanceName)
{
if (instanceName != null)
AddActorNameError(errors, "Instance", instanceName, instanceName);
foreach (var script in config.Scripts)
AddActorNameError(errors, "Script", script.CanonicalName, $"script-{script.CanonicalName}");
foreach (var alarm in config.Alarms)
AddActorNameError(errors, "Alarm", alarm.CanonicalName, $"alarm-{alarm.CanonicalName}");
foreach (var source in config.NativeAlarmSources)
AddActorNameError(errors, "Native alarm source", source.CanonicalName,
$"native-alarm-{source.CanonicalName}");
}
private static void AddActorNameError(
List<string> errors, string kind, string? name, string actorName)
{
if (ActorPath.IsValidPathElement(actorName))
return;
errors.Add($"{ActorNameErrorPrefix}{kind} name '{name}' cannot be used as a runtime actor " +
"name — use ASCII letters, digits and -_.*$+:@&=,!~';() only (no spaces, '#', " +
"'/', '[', ']', '%' or non-ASCII letters), and do not start it with '$'.");
}
/// <summary>
/// Compiles the boolean trigger expression for an Expression-triggered script
/// or alarm and records any failure. No-op for non-Expression triggers or a