Phase 3B: Site I/O & Observability — Communication, DCL, Script/Alarm actors, Health, Event Logging
Communication Layer (WP-1–5): - 8 message patterns with correlation IDs, per-pattern timeouts - Central/Site communication actors, transport heartbeat config - Connection failure handling (no central buffering, debug streams killed) Data Connection Layer (WP-6–14, WP-34): - Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting) - OPC UA + LmxProxy adapters behind IDataConnection - Auto-reconnect, bad quality propagation, transparent re-subscribe - Write-back, tag path resolution with retry, health reporting - Protocol extensibility via DataConnectionFactory Site Runtime (WP-15–25, WP-32–33): - ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher) - AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state) - SharedScriptLibrary (inline execution), ScriptRuntimeContext (API) - ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout) - Recursion limit (default 10), call direction enforcement - SiteStreamManager (per-subscriber bounded buffers, fire-and-forget) - Debug view backend (snapshot + stream), concurrency serialization - Local artifact storage (4 SQLite tables) Health Monitoring (WP-26–28): - SiteHealthCollector (thread-safe counters, connection state) - HealthReportSender (30s interval, monotonic sequence numbers) - CentralHealthAggregator (offline detection 60s, online recovery) Site Event Logging (WP-29–31): - SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC) - EventLogPurgeService (30-day retention, 1GB cap) - EventLogQueryService (filters, keyword search, keyset pagination) 541 tests pass, zero warnings.
This commit is contained in:
181
src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs
Normal file
181
src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
|
||||
/// Forbidden APIs: System.IO, Process, Threading (except async/await), Reflection,
|
||||
/// System.Net.Sockets, System.Net.Http.
|
||||
/// </summary>
|
||||
public class ScriptCompilationService
|
||||
{
|
||||
private readonly ILogger<ScriptCompilationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Namespaces that are forbidden in user scripts for security.
|
||||
/// </summary>
|
||||
private static readonly string[] ForbiddenNamespaces =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading",
|
||||
"System.Reflection",
|
||||
"System.Net.Sockets",
|
||||
"System.Net.Http"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Specific types/members allowed even within forbidden namespaces.
|
||||
/// async/await is OK despite System.Threading being blocked.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExceptions =
|
||||
[
|
||||
"System.Threading.Tasks",
|
||||
"System.Threading.CancellationToken",
|
||||
"System.Threading.CancellationTokenSource"
|
||||
];
|
||||
|
||||
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the script source code does not reference forbidden APIs.
|
||||
/// Returns a list of violation messages, empty if clean.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ValidateTrustModel(string code)
|
||||
{
|
||||
var violations = new List<string>();
|
||||
var tree = CSharpSyntaxTree.ParseText(code);
|
||||
var root = tree.GetRoot();
|
||||
var text = root.ToFullString();
|
||||
|
||||
foreach (var ns in ForbiddenNamespaces)
|
||||
{
|
||||
if (text.Contains(ns, StringComparison.Ordinal))
|
||||
{
|
||||
// Check if it matches an allowed exception
|
||||
var isAllowed = AllowedExceptions.Any(allowed =>
|
||||
text.Contains(allowed, StringComparison.Ordinal) &&
|
||||
ns != allowed &&
|
||||
allowed.StartsWith(ns, StringComparison.Ordinal));
|
||||
|
||||
// More precise: check each occurrence
|
||||
var idx = 0;
|
||||
while ((idx = text.IndexOf(ns, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var remainder = text.Substring(idx);
|
||||
var matchesAllowed = AllowedExceptions.Any(a =>
|
||||
remainder.StartsWith(a, StringComparison.Ordinal));
|
||||
|
||||
if (!matchesAllowed)
|
||||
{
|
||||
violations.Add($"Forbidden API reference: '{ns}' at position {idx}");
|
||||
break;
|
||||
}
|
||||
idx += ns.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
/// and parameters dictionary, and returns an object? result.
|
||||
/// </summary>
|
||||
public ScriptCompilationResult Compile(string scriptName, string code)
|
||||
{
|
||||
// Validate trust model
|
||||
var violations = ValidateTrustModel(code);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} failed trust validation: {Violations}",
|
||||
scriptName, string.Join("; ", violations));
|
||||
return ScriptCompilationResult.Failed(violations);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scriptOptions = ScriptOptions.Default
|
||||
.WithReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Math).Assembly)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
var script = CSharpScript.Create<object?>(
|
||||
code,
|
||||
scriptOptions,
|
||||
globalsType: typeof(ScriptGlobals));
|
||||
|
||||
var diagnostics = script.Compile();
|
||||
var errors = diagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} compilation failed: {Errors}",
|
||||
scriptName, string.Join("; ", errors));
|
||||
return ScriptCompilationResult.Failed(errors);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Script {Script} compiled successfully", scriptName);
|
||||
return ScriptCompilationResult.Succeeded(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName);
|
||||
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of script compilation, containing either the compiled script or error messages.
|
||||
/// </summary>
|
||||
public class ScriptCompilationResult
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public Script<object?>? CompiledScript { get; }
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
|
||||
{
|
||||
IsSuccess = success;
|
||||
CompiledScript = script;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
|
||||
new(true, script, []);
|
||||
|
||||
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
|
||||
new(false, null, errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
|
||||
/// as the "Instance" global, and parameters are available via "Parameters".
|
||||
/// </summary>
|
||||
public class ScriptGlobals
|
||||
{
|
||||
public ScriptRuntimeContext Instance { get; set; } = null!;
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } =
|
||||
new Dictionary<string, object?>();
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
}
|
||||
172
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
Normal file
172
src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors.
|
||||
/// Provides the API surface that user scripts interact with:
|
||||
/// Instance.GetAttribute("name")
|
||||
/// Instance.SetAttribute("name", value)
|
||||
/// Instance.CallScript("scriptName", params)
|
||||
/// Scripts.CallShared("scriptName", params)
|
||||
///
|
||||
/// WP-20: Recursion Limit — call depth tracked and enforced.
|
||||
/// </summary>
|
||||
public class ScriptRuntimeContext
|
||||
{
|
||||
private readonly IActorRef _instanceActor;
|
||||
private readonly IActorRef _self;
|
||||
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||
private readonly int _currentCallDepth;
|
||||
private readonly int _maxCallDepth;
|
||||
private readonly TimeSpan _askTimeout;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _instanceName;
|
||||
|
||||
public ScriptRuntimeContext(
|
||||
IActorRef instanceActor,
|
||||
IActorRef self,
|
||||
SharedScriptLibrary sharedScriptLibrary,
|
||||
int currentCallDepth,
|
||||
int maxCallDepth,
|
||||
TimeSpan askTimeout,
|
||||
string instanceName,
|
||||
ILogger logger)
|
||||
{
|
||||
_instanceActor = instanceActor;
|
||||
_self = self;
|
||||
_sharedScriptLibrary = sharedScriptLibrary;
|
||||
_currentCallDepth = currentCallDepth;
|
||||
_maxCallDepth = maxCallDepth;
|
||||
_askTimeout = askTimeout;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current value of an attribute from the Instance Actor.
|
||||
/// Uses Ask pattern (system boundary between script execution and instance state).
|
||||
/// </summary>
|
||||
public async Task<object?> GetAttribute(string attributeName)
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var request = new GetAttributeRequest(
|
||||
correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);
|
||||
|
||||
if (!response.Found)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'",
|
||||
attributeName, _instanceName);
|
||||
}
|
||||
|
||||
return response.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute value. For data-connected attributes, forwards to DCL via Instance Actor.
|
||||
/// For static attributes, updates in-memory and persists to SQLite via Instance Actor.
|
||||
/// All mutations serialized through the Instance Actor mailbox.
|
||||
/// </summary>
|
||||
public void SetAttribute(string attributeName, string value)
|
||||
{
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var command = new SetStaticAttributeCommand(
|
||||
correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow);
|
||||
|
||||
// Tell (fire-and-forget) — mutation serialized through Instance Actor
|
||||
_instanceActor.Tell(command);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a sibling script on the same instance by name (Ask pattern).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
/// WP-22: Uses Ask pattern for CallScript.
|
||||
/// </summary>
|
||||
public async Task<object?> CallScript(string scriptName, IReadOnlyDictionary<string, object?>? parameters = null)
|
||||
{
|
||||
var nextDepth = _currentCallDepth + 1;
|
||||
if (nextDepth > _maxCallDepth)
|
||||
{
|
||||
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
||||
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
|
||||
_logger.LogError(msg);
|
||||
throw new InvalidOperationException(msg);
|
||||
}
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
var request = new ScriptCallRequest(
|
||||
scriptName,
|
||||
parameters,
|
||||
nextDepth,
|
||||
correlationId);
|
||||
|
||||
// Ask the Instance Actor, which routes to the appropriate Script Actor
|
||||
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"CallScript('{scriptName}') failed: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return result.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to shared script execution via the Scripts property.
|
||||
/// </summary>
|
||||
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for Scripts.CallShared() syntax.
|
||||
/// </summary>
|
||||
public class ScriptCallHelper
|
||||
{
|
||||
private readonly SharedScriptLibrary _library;
|
||||
private readonly ScriptRuntimeContext _context;
|
||||
private readonly int _currentCallDepth;
|
||||
private readonly int _maxCallDepth;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal ScriptCallHelper(
|
||||
SharedScriptLibrary library,
|
||||
ScriptRuntimeContext context,
|
||||
int currentCallDepth,
|
||||
int maxCallDepth,
|
||||
ILogger logger)
|
||||
{
|
||||
_library = library;
|
||||
_context = context;
|
||||
_currentCallDepth = currentCallDepth;
|
||||
_maxCallDepth = maxCallDepth;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Executes a shared script inline (direct method call, not actor message).
|
||||
/// WP-20: Enforces recursion limit.
|
||||
/// </summary>
|
||||
public async Task<object?> CallShared(
|
||||
string scriptName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var nextDepth = _currentCallDepth + 1;
|
||||
if (nextDepth > _maxCallDepth)
|
||||
{
|
||||
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
||||
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
|
||||
_logger.LogError(msg);
|
||||
throw new InvalidOperationException(msg);
|
||||
}
|
||||
|
||||
return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs
Normal file
114
src/ScadaLink.SiteRuntime/Scripts/SharedScriptLibrary.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Shared Script Library — stores compiled shared script delegates in memory.
|
||||
/// Shared scripts are compiled when received from central and executed inline
|
||||
/// (direct method call, not actor message). NOT available on central.
|
||||
/// WP-33: Recompiled on update when new artifacts arrive.
|
||||
/// </summary>
|
||||
public class SharedScriptLibrary
|
||||
{
|
||||
private readonly ScriptCompilationService _compilationService;
|
||||
private readonly ILogger<SharedScriptLibrary> _logger;
|
||||
private readonly Dictionary<string, Script<object?>> _compiledScripts = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SharedScriptLibrary(
|
||||
ScriptCompilationService compilationService,
|
||||
ILogger<SharedScriptLibrary> logger)
|
||||
{
|
||||
_compilationService = compilationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and registers a shared script. Replaces any existing script with the same name.
|
||||
/// Returns true if compilation succeeded, false otherwise.
|
||||
/// </summary>
|
||||
public bool CompileAndRegister(string name, string code)
|
||||
{
|
||||
var result = _compilationService.Compile(name, code);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Shared script '{Name}' failed to compile: {Errors}",
|
||||
name, string.Join("; ", result.Errors));
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_compiledScripts[name] = result.CompiledScript!;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Shared script '{Name}' compiled and registered", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a shared script from the library.
|
||||
/// </summary>
|
||||
public bool Remove(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.Remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a shared script inline with the given runtime context.
|
||||
/// This is a direct method call, not an actor message — executes on the calling thread.
|
||||
/// </summary>
|
||||
public async Task<object?> ExecuteAsync(
|
||||
string scriptName,
|
||||
ScriptRuntimeContext context,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Script<object?> script;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_compiledScripts.TryGetValue(scriptName, out script!))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Shared script '{scriptName}' not found in library.");
|
||||
}
|
||||
}
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
Instance = context,
|
||||
Parameters = parameters ?? new Dictionary<string, object?>(),
|
||||
CancellationToken = cancellationToken
|
||||
};
|
||||
|
||||
var state = await script.RunAsync(globals, cancellationToken);
|
||||
return state.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names of all currently registered shared scripts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetRegisteredScriptNames()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.Keys.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a script with the given name is registered.
|
||||
/// </summary>
|
||||
public bool Contains(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.ContainsKey(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user