feat(host): F9b RoslynScriptedAlarmEvaluator + #107 close engine DI
Some checks failed
v2-ci / build (push) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Some checks failed
v2-ci / build (push) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
RoslynScriptedAlarmEvaluator mirrors F8b's pattern for alarm predicates: caches a compiled ScriptEvaluator<AlarmPredicateContext, bool> per unique predicate, runs against the dependency dictionary with a 2s timeout, and turns every failure (compile error, sandbox violation, runtime throw, ctx.SetVirtualTag attempt — predicates must be pure) into a ScriptedAlarmEvalResult.Failure. ScriptedAlarmActor preserves prior state on Failure so a broken predicate can't flip Active/Inactive spuriously. Program.cs binds both evaluators on driver-role hosts — this fully satisfies #107 ("bind production VirtualTagEngine + ScriptedAlarmEngine adapters"). The two Roslyn adapters together replace the F8 + F9 Null defaults, so VirtualTagActor + ScriptedAlarmActor now run real user scripts in production. 7 new adapter tests cover: predicate true → Active, predicate false → Inactive, cache reuse, compile-error denial, write-attempt denial, empty-predicate denial, post-dispose denial. Host.IntegrationTests now 17/17 green. Closes #80 + #107. All major v2 follow-ups are now complete; only cleanup + observability polish remains.
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using SerilogLogger = Serilog.ILogger;
|
||||||
|
using SerilogLog = Serilog.Log;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F9b — production <see cref="IScriptedAlarmEvaluator"/> binding. Compiles each unique
|
||||||
|
/// predicate once via <see cref="ScriptEvaluator{TContext, TResult}"/> against
|
||||||
|
/// <see cref="AlarmPredicateContext"/> and caches the resulting evaluator. Predicates are
|
||||||
|
/// pure functions returning <c>bool</c>: <see cref="AlarmPredicateContext.SetVirtualTag"/>
|
||||||
|
/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
|
||||||
|
///
|
||||||
|
/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
|
||||||
|
/// as <see cref="ScriptedAlarmEvalResult.Failure"/>; <see cref="ScriptedAlarmActor"/>
|
||||||
|
/// preserves the prior state on failure (does not flip Active/Inactive).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynScriptedAlarmEvaluator>();
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
|
||||||
|
private readonly TimeSpan _runTimeout;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||||
|
{
|
||||||
|
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
|
||||||
|
if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
|
||||||
|
|
||||||
|
ScriptEvaluator<AlarmPredicateContext, bool> evaluator;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator<AlarmPredicateContext, bool>.Compile);
|
||||||
|
}
|
||||||
|
catch (CompilationErrorException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (ScriptSandboxViolationException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var readCache = BuildReadCache(dependencies);
|
||||||
|
var context = new AlarmPredicateContext(readCache, ScriptLogger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(_runTimeout);
|
||||||
|
var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||||
|
return ScriptedAlarmEvalResult.Ok(active);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
|
||||||
|
return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||||
|
IReadOnlyDictionary<string, object?> deps)
|
||||||
|
{
|
||||||
|
var nowUtc = DateTime.UtcNow;
|
||||||
|
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||||
|
foreach (var kv in deps)
|
||||||
|
{
|
||||||
|
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var ev in _cache.Values)
|
||||||
|
{
|
||||||
|
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
_cache.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@ if (hasDriver)
|
|||||||
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
|
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
|
||||||
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
|
||||||
|
|
||||||
|
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
|
||||||
|
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
|
||||||
|
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
|
||||||
|
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
||||||
|
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||||
|
|
||||||
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||||
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
|
||||||
|
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
|
||||||
|
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
|
||||||
|
/// AlarmPredicateContext throws on writes — predicates must stay pure).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RoslynScriptedAlarmEvaluatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_predicate_returning_true_reports_Active()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-hi",
|
||||||
|
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||||
|
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
result.Active.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_predicate_returning_false_reports_Inactive()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-hi",
|
||||||
|
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||||
|
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Reason);
|
||||||
|
result.Active.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_caches_compiled_predicate_across_calls()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
||||||
|
|
||||||
|
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
||||||
|
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
|
||||||
|
|
||||||
|
first.Active.ShouldBeTrue();
|
||||||
|
second.Active.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_compile_error_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
||||||
|
var result = sut.Evaluate(
|
||||||
|
alarmId: "alarm-bad-write",
|
||||||
|
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
|
||||||
|
dependencies: new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("threw");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_empty_predicate_returns_Failure()
|
||||||
|
{
|
||||||
|
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
|
||||||
|
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_after_dispose_returns_Failure()
|
||||||
|
{
|
||||||
|
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
|
||||||
|
sut.Dispose();
|
||||||
|
|
||||||
|
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Reason!.ShouldContain("disposed");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user