fix(site-runtime): site-side compile failure now rejects the deployment per spec (S3) — synchronous validation gate before actor creation
Validation runs on the actor thread (not off-thread as the plan sketched): piping the verdict back to self reorders concurrent deploys relative to each other and to delete/disable, breaking the redeploy-supersede FIFO ordering (SR020/SR029). A deploy is an infrequent admin command, so a brief synchronous Roslyn compile is acceptable.
This commit is contained in:
@@ -35,6 +35,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
private readonly SiteStorageService _storage;
|
private readonly SiteStorageService _storage;
|
||||||
private readonly ScriptCompilationService _compilationService;
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
// Pure, off-thread deploy-time compile gate. A site-side compile failure
|
||||||
|
// must reject the deployment (spec: no partial state applied), so every
|
||||||
|
// DeployInstanceCommand is validated before any actor/persistence work.
|
||||||
|
private readonly DeployCompileValidator _deployCompileValidator;
|
||||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||||
private readonly SiteStreamManager? _streamManager;
|
private readonly SiteStreamManager? _streamManager;
|
||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
@@ -125,6 +129,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_compilationService = compilationService;
|
_compilationService = compilationService;
|
||||||
|
_deployCompileValidator = new DeployCompileValidator(compilationService);
|
||||||
_sharedScriptLibrary = sharedScriptLibrary;
|
_sharedScriptLibrary = sharedScriptLibrary;
|
||||||
_streamManager = streamManager;
|
_streamManager = streamManager;
|
||||||
_options = options;
|
_options = options;
|
||||||
@@ -387,6 +392,41 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
/// apply still replies to the right actor.
|
/// apply still replies to the right actor.
|
||||||
/// </param>
|
/// </param>
|
||||||
private void HandleDeploy(DeployInstanceCommand command, IActorRef replyTo)
|
private void HandleDeploy(DeployInstanceCommand command, IActorRef replyTo)
|
||||||
|
{
|
||||||
|
// Site-side compile gate (S3): a compile failure must reject the
|
||||||
|
// deployment with NO partial state applied (no Instance Actor, no
|
||||||
|
// persisted config) — the design spec's contract. Validation runs
|
||||||
|
// synchronously on the actor thread: it is a pure prefix step of the
|
||||||
|
// deploy handler, so the existing redeploy-supersede / delete-during-
|
||||||
|
// redeploy ordering (which depends on strict mailbox FIFO) is preserved
|
||||||
|
// exactly. It is NOT run off-thread — piping the verdict back to self
|
||||||
|
// reorders concurrent deploys relative to each other and to
|
||||||
|
// delete/disable commands, breaking that ordering. A deploy is an
|
||||||
|
// infrequent admin command, so briefly holding the singleton for a pure
|
||||||
|
// Roslyn compile is acceptable; the central deployer already Asks and
|
||||||
|
// waits for the DeploymentStatusResponse.
|
||||||
|
var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
var instanceName = command.InstanceUniqueName;
|
||||||
|
var joined = string.Join(" | ", errors);
|
||||||
|
LogDeploymentEvent("Error", instanceName,
|
||||||
|
$"Instance {instanceName} deploy rejected — site compile validation failed (deploymentId={command.DeploymentId})",
|
||||||
|
joined);
|
||||||
|
replyTo.Tell(new DeploymentStatusResponse(
|
||||||
|
command.DeploymentId, instanceName, DeploymentStatus.Failed,
|
||||||
|
"Script compilation failed on site: " + joined, DateTimeOffset.UtcNow));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProceedWithDeploy(command, replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core deploy application (redeploy-supersede + fresh apply), reached only
|
||||||
|
/// after the synchronous compile gate in <see cref="HandleDeploy"/> passes.
|
||||||
|
/// </summary>
|
||||||
|
private void ProceedWithDeploy(DeployInstanceCommand command, IActorRef replyTo)
|
||||||
{
|
{
|
||||||
var instanceName = command.InstanceUniqueName;
|
var instanceName = command.InstanceUniqueName;
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
|
|||||||
@@ -1336,7 +1336,9 @@ public class InstanceActor : ReceiveActor
|
|||||||
/// Creates child Script Actors and Alarm Actors from the flattened configuration.
|
/// Creates child Script Actors and Alarm Actors from the flattened configuration.
|
||||||
/// Script Actors spawned per script definition.
|
/// Script Actors spawned per script definition.
|
||||||
/// Alarm Actors spawned per alarm definition, as peers to Script Actors.
|
/// Alarm Actors spawned per alarm definition, as peers to Script Actors.
|
||||||
/// Compilation errors reject entire instance deployment (logged but actor still starts).
|
/// Compile failures are rejected at deploy time by the Deployment Manager's
|
||||||
|
/// validation gate; a failure reaching this point (startup load of a legacy
|
||||||
|
/// config) is logged and the offending script/alarm skipped.
|
||||||
///
|
///
|
||||||
/// Each child is seeded from a private point-in-time snapshot
|
/// Each child is seeded from a private point-in-time snapshot
|
||||||
/// of <c>_attributes</c>, NOT the live dictionary. The snapshot is taken here on
|
/// of <c>_attributes</c>, NOT the live dictionary. The snapshot is taken here on
|
||||||
|
|||||||
@@ -232,6 +232,40 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal("NewPump", response.InstanceUniqueName);
|
Assert.Equal("NewPump", response.InstanceUniqueName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── S3: site-side compile failure rejects the deployment (no partial state) ──
|
||||||
|
|
||||||
|
private static string MakeConfigWithBrokenScriptJson(string instanceName) =>
|
||||||
|
JsonSerializer.Serialize(new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = instanceName,
|
||||||
|
Attributes = [new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }],
|
||||||
|
Scripts = [new ResolvedScript { CanonicalName = "S1", Code = "this is not C# ~~~" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deploy_WithNonCompilingScript_ReportsFailed_AndCreatesNoInstanceActor()
|
||||||
|
{
|
||||||
|
var actor = CreateDeploymentManager();
|
||||||
|
await Task.Delay(500); // wait for empty startup
|
||||||
|
|
||||||
|
actor.Tell(new DeployInstanceCommand(
|
||||||
|
"dep-bad", "bad-instance", "r1",
|
||||||
|
MakeConfigWithBrokenScriptJson("bad-instance"), "admin", DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var resp = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Equal(DeploymentStatus.Failed, resp.Status);
|
||||||
|
Assert.NotNull(resp.ErrorMessage);
|
||||||
|
Assert.Contains("compil", resp.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// No partial state applied: instance actor must not exist …
|
||||||
|
Sys.ActorSelection(actor.Path / "bad-instance").Tell(new Identify(1));
|
||||||
|
Assert.Null(ExpectMsg<ActorIdentity>().Subject);
|
||||||
|
|
||||||
|
// … and no config must have been persisted.
|
||||||
|
var configs = await _storage.GetAllDeployedConfigsAsync();
|
||||||
|
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "bad-instance");
|
||||||
|
}
|
||||||
|
|
||||||
// ── M1.6: site event log `deployment` category ─────────────────────────
|
// ── M1.6: site event log `deployment` category ─────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user