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:
Joseph Doherty
2026-03-16 20:57:25 -04:00
parent a3bf0c43f3
commit 389f5a0378
97 changed files with 8308 additions and 127 deletions

View 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; }
}

View 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);
}
}
}

View 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);
}
}
}