From e20c329cad52a594ca8bef19f1f3c8c33e1e93d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 8 Jul 2026 23:21:42 -0400 Subject: [PATCH] feat(site-runtime): pure deploy-time compile validator for flattened configs (S3 groundwork) --- .../Deployment/DeployCompileValidator.cs | 117 ++++++++++++++++++ .../Deployment/DeployCompileValidatorTests.cs | 102 +++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs new file mode 100644 index 00000000..d2241395 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; + +/// +/// Pure, thread-safe deploy-time gate: given a flattened-configuration JSON, it +/// compiles every script, every alarm on-trigger script, and every Expression +/// trigger just as does at +/// runtime — but WITHOUT touching any actor state. It returns the collected +/// compile errors so the Deployment Manager can reject a deployment whose scripts +/// do not compile on the site (design spec: site-side compile failure rejects the +/// deployment; no partial state is applied). +/// +/// Because it holds no actor state and only calls the (stateless) +/// , it is safe to run off the singleton +/// mailbox on a Task.Run thread — the deploy path never blocks on Roslyn. +/// +public sealed class DeployCompileValidator +{ + private readonly ScriptCompilationService _compilationService; + + /// Initializes a new instance of the . + /// The same compilation service the Instance Actor uses at runtime. + public DeployCompileValidator(ScriptCompilationService compilationService) + => _compilationService = compilationService; + + /// + /// Validates every compilable artifact in the flattened configuration. + /// Returns an empty list when the whole configuration compiles; otherwise + /// one human-readable error per failing artifact. Malformed JSON is itself a + /// single error (this also fronts the poison-config case for startup load). + /// + /// The flattened-configuration JSON to validate. + /// A list of compile-error messages; empty if the configuration is clean. + public IReadOnlyList Validate(string configJson) + { + FlattenedConfiguration? config; + try + { + config = JsonSerializer.Deserialize(configJson); + } + catch (JsonException ex) + { + return [$"Configuration JSON failed to deserialize: {ex.Message}"]; + } + + if (config == null) + return ["Configuration JSON failed to deserialize: result was null."]; + + var errors = new List(); + + // Scripts: full execution-path compile against ScriptGlobals. + foreach (var script in config.Scripts) + { + var result = _compilationService.Compile(script.CanonicalName, script.Code); + if (!result.IsSuccess) + errors.Add($"Script '{script.CanonicalName}': {string.Join("; ", result.Errors)}"); + + // Expression-triggered scripts also compile their trigger expression. + AddTriggerExpressionError(errors, script.TriggerType, script.TriggerConfiguration, + $"script-trigger-{script.CanonicalName}"); + } + + // Alarms: on-trigger script reference must resolve + compile, and any + // Expression trigger must compile — mirrors InstanceActor.CreateChildActors. + foreach (var alarm in config.Alarms) + { + if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName)) + { + var triggerScriptDef = config.Scripts + .FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName); + if (triggerScriptDef == null) + { + errors.Add($"Alarm '{alarm.CanonicalName}': on-trigger script " + + $"'{alarm.OnTriggerScriptCanonicalName}' is not defined."); + } + else + { + var result = _compilationService.Compile( + $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code); + if (!result.IsSuccess) + errors.Add($"Alarm '{alarm.CanonicalName}' on-trigger script " + + $"'{alarm.OnTriggerScriptCanonicalName}': {string.Join("; ", result.Errors)}"); + } + } + + AddTriggerExpressionError(errors, alarm.TriggerType, alarm.TriggerConfiguration, + $"alarm-trigger-expr-{alarm.CanonicalName}"); + } + + return errors; + } + + /// + /// Compiles the boolean trigger expression for an Expression-triggered script + /// or alarm and records any failure. No-op for non-Expression triggers or a + /// blank/malformed expression config — mirroring + /// InstanceActor.CompileTriggerExpression, where those cases yield an + /// inert trigger rather than a deployment error. + /// + private void AddTriggerExpressionError( + List errors, string? triggerType, string? triggerConfigJson, string compileName) + { + if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase)) + return; + + var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson); + if (expression == null) + return; + + var result = _compilationService.CompileTriggerExpression(compileName, expression); + if (!result.IsSuccess) + errors.Add($"Trigger expression '{compileName}': {string.Join("; ", result.Errors)}"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs new file mode 100644 index 00000000..81c36ece --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Deployment/DeployCompileValidatorTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Deployment; + +/// +/// Site Runtime S3: the pure deploy-time compile validator. It mirrors the +/// compile work +/// does at runtime (scripts, on-trigger scripts, Expression triggers) so the +/// Deployment Manager can reject a non-compiling deployment before any actor is +/// created — WITHOUT touching actor state (safe on a Task.Run thread). +/// +public class DeployCompileValidatorTests +{ + private static DeployCompileValidator NewValidator() => + new(new ScriptCompilationService(NullLogger.Instance)); + + private static string ConfigWith(string scriptCode) => JsonSerializer.Serialize(new FlattenedConfiguration + { + Scripts = new List + { + new() { CanonicalName = "S1", Code = scriptCode, TriggerType = "Call" } + }, + Attributes = new List(), + Alarms = new List(), + NativeAlarmSources = new List() + }); + + [Fact] + public void BrokenScript_ReturnsErrors() + { + var errors = NewValidator().Validate(ConfigWith("this is not C# ~~~")); + Assert.NotEmpty(errors); + Assert.Contains(errors, e => e.Contains("S1")); + } + + [Fact] + public void ValidScript_ReturnsNoErrors() + { + Assert.Empty(NewValidator().Validate(ConfigWith("return 1 + 1;"))); + } + + [Fact] + public void BrokenTriggerExpression_ReturnsErrors() + { + // One Expression-triggered script whose trigger expression "1 +" does not compile. + var cfg = JsonSerializer.Serialize(new FlattenedConfiguration + { + Scripts = new List + { + new() + { + CanonicalName = "S1", + Code = "return 1;", + TriggerType = "Expression", + TriggerConfiguration = JsonSerializer.Serialize(new { expression = "1 +" }) + } + }, + Attributes = new List(), + Alarms = new List(), + NativeAlarmSources = new List() + }); + + var errors = NewValidator().Validate(cfg); + Assert.NotEmpty(errors); + Assert.Contains(errors, e => e.Contains("script-trigger-S1")); + } + + [Fact] + public void MalformedJson_ReturnsSingleDeserializeError() + { + var errors = NewValidator().Validate("{ this is not json"); + Assert.Single(errors); + Assert.Contains("deserialize", errors[0], StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MissingOnTriggerScript_ReturnsError() + { + var cfg = JsonSerializer.Serialize(new FlattenedConfiguration + { + Scripts = new List(), + Attributes = new List(), + Alarms = new List + { + new() + { + CanonicalName = "A1", + TriggerType = "HiLo", + OnTriggerScriptCanonicalName = "DoesNotExist" + } + }, + NativeAlarmSources = new List() + }); + + var errors = NewValidator().Validate(cfg); + Assert.Contains(errors, e => e.Contains("A1") && e.Contains("DoesNotExist")); + } +}