diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs new file mode 100644 index 0000000..01edde1 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs @@ -0,0 +1,116 @@ +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.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.VirtualTags; +using SerilogLogger = Serilog.ILogger; +using SerilogLog = Serilog.Log; + +namespace ZB.MOM.WW.OtOpcUa.Host.Engines; + +/// +/// F8b — production binding. Compiles each unique +/// expression once via (Roslyn-backed +/// sandbox) and caches the resulting evaluator keyed by source. Subsequent evaluations are +/// in-process method invocations on the dependency dictionary — fast enough to run inline +/// inside the actor's message handler. +/// +/// Single-tag mode: cross-tag ctx.SetVirtualTag writes are dropped (logged) because +/// fan-out between actors is owned by DependencyMuxActor, not by the eval engine. +/// Cycle detection + cascade ordering live in ; this adapter +/// stays single-tag scoped to keep 's message loop simple. +/// +public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable +{ + private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext(); + + private readonly ConcurrentDictionary> _cache + = new(StringComparer.Ordinal); + private readonly ILogger _logger; + private readonly TimeSpan _runTimeout; + private bool _disposed; + + public RoslynVirtualTagEvaluator(ILogger logger, TimeSpan? runTimeout = null) + { + _logger = logger; + _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2); + } + + public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary dependencies) + { + if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed"); + if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression"); + + ScriptEvaluator evaluator; + try + { + evaluator = _cache.GetOrAdd(expression, ScriptEvaluator.Compile); + } + catch (CompilationErrorException ex) + { + _logger.LogWarning(ex, "VirtualTag {Id}: Roslyn compile failed", virtualTagId); + return VirtualTagEvalResult.Failure($"compile error: {ex.Message}"); + } + catch (ScriptSandboxViolationException ex) + { + _logger.LogWarning(ex, "VirtualTag {Id}: sandbox violation", virtualTagId); + return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "VirtualTag {Id}: compile threw", virtualTagId); + return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}"); + } + + var readCache = BuildReadCache(dependencies); + var context = new VirtualTagContext( + readCache, + setVirtualTag: (path, _) => + _logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)", + virtualTagId, path), + logger: ScriptLogger); + + try + { + using var cts = new CancellationTokenSource(_runTimeout); + var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult(); + return VirtualTagEvalResult.Ok(raw); + } + catch (OperationCanceledException) + { + return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "VirtualTag {Id}: script execution threw", virtualTagId); + return VirtualTagEvalResult.Failure($"script threw: {ex.Message}"); + } + } + + private static IReadOnlyDictionary BuildReadCache( + IReadOnlyDictionary deps) + { + // VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value + // as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null + // values stay null; the script can null-check via GetTag(path).Value. + var nowUtc = DateTime.UtcNow; + var cache = new Dictionary(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(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 3043d0a..57d5145 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -8,8 +8,10 @@ using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Host; using ZB.MOM.WW.OtOpcUa.Host.Drivers; +using ZB.MOM.WW.OtOpcUa.Host.Engines; using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Observability; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; @@ -70,6 +72,13 @@ if (hasDriver) // ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes // it isn't, so we register the LDAP options + service unconditionally for driver hosts // to keep parity. The LdapAdapter falls back to Deny on any backend error. + // F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression). + // Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user + // scripts at runtime. + builder.Services.AddSingleton(sp => + new RoslynVirtualTagEvaluator(sp.GetRequiredService().CreateLogger())); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Ldap")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index be53447..db80245 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -5,6 +5,12 @@ OtOpcUa.Host zb-mom-ww-otopcua-host true + + $(NoWarn);NU1608 @@ -24,6 +30,8 @@ + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs new file mode 100644 index 0000000..634795a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Engines; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// F8b — verifies compiles user expressions through +/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the +/// compiled assembly per source, and surfaces failures (compile error, sandbox violation, +/// runtime throw) as VirtualTagEvalResult.Failure instead of propagating exceptions. +/// +public sealed class RoslynVirtualTagEvaluatorTests +{ + [Fact] + public void Evaluate_simple_addition_returns_summed_value() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + virtualTagId: "vt-sum", + expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;", + dependencies: new Dictionary { ["a"] = 10, ["b"] = 32 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Value.ShouldBe(42); + } + + [Fact] + public void Evaluate_caches_compiled_expression_across_calls() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;"; + + var first = sut.Evaluate("vt-cache", expr, new Dictionary { ["x"] = 5 }); + var second = sut.Evaluate("vt-cache", expr, new Dictionary { ["x"] = 7 }); + + first.Success.ShouldBeTrue(first.Reason); + first.Value.ShouldBe(10); + second.Success.ShouldBeTrue(second.Reason); + second.Value.ShouldBe(14); + } + + [Fact] + public void Evaluate_compile_error_returns_Failure_with_reason() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("compile"); + } + + [Fact] + public void Evaluate_runtime_exception_returns_Failure_with_reason() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + virtualTagId: "vt-div0", + expression: "int a = 0; return 1 / a;", + dependencies: new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason.ShouldNotBeNull(); + result.Reason.ShouldContain("threw"); + } + + [Fact] + public void Evaluate_empty_expression_returns_Failure() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + sut.Evaluate("vt-empty", "", new Dictionary()).Success.ShouldBeFalse(); + sut.Evaluate("vt-empty", " ", new Dictionary()).Success.ShouldBeFalse(); + } + + [Fact] + public void Evaluate_after_dispose_returns_Failure() + { + var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + sut.Dispose(); + + var result = sut.Evaluate("vt", "return 1;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason!.ShouldContain("disposed"); + } +}