refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A: <see cref="DbCommand"/> decorator that emits
|
||||
/// exactly one <c>DbOutbound</c>/<c>DbWrite</c> audit event per execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Vocabulary lock (M4 plan):</b> both writes (Execute / ExecuteScalar) and
|
||||
/// reads (ExecuteReader) emit <see cref="AuditKind.DbWrite"/> on the
|
||||
/// <see cref="AuditChannel.DbOutbound"/> channel. The <c>Extra</c> JSON column
|
||||
/// distinguishes them — <c>{"op":"write","rowsAffected":N}</c> for writes,
|
||||
/// <c>{"op":"read","rowsReturned":N}</c> for reads.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Best-effort emission (alog.md §7):</b> mirrors
|
||||
/// <see cref="ScriptRuntimeContext.ExternalSystemHelper"/>'s 3-layer fail-safe.
|
||||
/// The original ADO.NET result (or original exception) flows back to the
|
||||
/// script untouched; audit-build, audit-write, and audit-continuation faults
|
||||
/// are all logged + swallowed. A faulted <see cref="IAuditWriter"/> never
|
||||
/// aborts the SQL call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditingDbCommand : DbCommand
|
||||
{
|
||||
private readonly DbCommand _inner;
|
||||
private readonly IAuditWriter _auditWriter;
|
||||
private readonly string _connectionName;
|
||||
private readonly string _siteId;
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
|
||||
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
|
||||
/// alongside <see cref="_executionId"/> and stamped onto the <c>DbWrite</c>
|
||||
/// audit row.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
private DbConnection? _wrappingConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new auditing database command decorator.
|
||||
/// </summary>
|
||||
/// <param name="inner">The inner database command to wrap and audit.</param>
|
||||
/// <param name="auditWriter">Writer for audit log entries.</param>
|
||||
/// <param name="connectionName">Name of the database connection being used.</param>
|
||||
/// <param name="siteId">Identifier of the site executing the command.</param>
|
||||
/// <param name="instanceName">Unique name of the instance executing the command.</param>
|
||||
/// <param name="sourceScript">Optional name of the source script for audit trail.</param>
|
||||
/// <param name="logger">Logger for diagnostics and warnings.</param>
|
||||
/// <param name="executionId">Unique identifier for this script execution.</param>
|
||||
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
|
||||
// Parameter ordering: executionId sits immediately after the ILogger,
|
||||
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
|
||||
// DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing
|
||||
// optional param so existing positional callers stay source-compatible.
|
||||
public AuditingDbCommand(
|
||||
DbCommand inner,
|
||||
IAuditWriter auditWriter,
|
||||
string connectionName,
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
|
||||
_siteId = siteId ?? string.Empty;
|
||||
_instanceName = instanceName ?? string.Empty;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_executionId = executionId;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
// -- Forwarded surface ------------------------------------------------
|
||||
|
||||
#pragma warning disable CS8765 // ADO.NET base members carry pre-NRT signatures with permissive nullability
|
||||
/// <inheritdoc />
|
||||
public override string CommandText
|
||||
{
|
||||
get => _inner.CommandText;
|
||||
set => _inner.CommandText = value;
|
||||
}
|
||||
#pragma warning restore CS8765
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int CommandTimeout
|
||||
{
|
||||
get => _inner.CommandTimeout;
|
||||
set => _inner.CommandTimeout = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override CommandType CommandType
|
||||
{
|
||||
get => _inner.CommandType;
|
||||
set => _inner.CommandType = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool DesignTimeVisible
|
||||
{
|
||||
get => _inner.DesignTimeVisible;
|
||||
set => _inner.DesignTimeVisible = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override UpdateRowSource UpdatedRowSource
|
||||
{
|
||||
get => _inner.UpdatedRowSource;
|
||||
set => _inner.UpdatedRowSource = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbConnection? DbConnection
|
||||
{
|
||||
// When the script has wrapped the connection (the normal path through
|
||||
// ScriptRuntimeContext.DatabaseHelper.Connection) we keep returning
|
||||
// the wrapper, but writes from the user go through to the inner
|
||||
// command so the underlying provider keeps its wiring intact.
|
||||
get => _wrappingConnection ?? _inner.Connection;
|
||||
// SiteRuntime-022: unwrap the AuditingDbConnection wrapper via its
|
||||
// own internal Inner accessor instead of reflecting into a private
|
||||
// _inner field. Reflection was the original SiteRuntime-006 anti-
|
||||
// pattern (and is forbidden inside script bodies by the trust
|
||||
// model) — both classes are internal sealed in the same assembly,
|
||||
// so the proper API surface is available without leaking anything
|
||||
// public.
|
||||
set
|
||||
{
|
||||
_wrappingConnection = value;
|
||||
_inner.Connection = value switch
|
||||
{
|
||||
AuditingDbConnection auditing => auditing.Inner,
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbParameterCollection DbParameterCollection => _inner.Parameters;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbTransaction? DbTransaction
|
||||
{
|
||||
get => _inner.Transaction;
|
||||
set => _inner.Transaction = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Cancel() => _inner.Cancel();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Prepare() => _inner.Prepare();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbParameter CreateDbParameter() => _inner.CreateParameter();
|
||||
|
||||
// -- Audited execution surface ---------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int ExecuteNonQuery()
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
int rows = 0;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
rows = _inner.ExecuteNonQuery();
|
||||
return rows;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? rows : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? ExecuteScalar()
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
object? scalar = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
scalar = _inner.ExecuteScalar();
|
||||
return scalar;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// ExecuteScalar is classified as "write" per the M4 vocabulary
|
||||
// lock — it's a single-value execution; rowsAffected mirrors the
|
||||
// inner command's value if exposed (DbCommand has no RecordsAffected
|
||||
// property, so we report -1 when the provider didn't surface it).
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? -1 : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
int rows = 0;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
rows = await _inner.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
return rows;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? rows : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<object?> ExecuteScalarAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
object? scalar = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
scalar = await _inner.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return scalar;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "write",
|
||||
rowsAffected: thrown == null ? -1 : (int?)null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
DbDataReader? reader = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
reader = _inner.ExecuteReader(behavior);
|
||||
return new AuditingDbDataReader(
|
||||
reader,
|
||||
onClose: rows => EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: rows,
|
||||
thrown: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
// Emit the failure row immediately — no reader to wait on.
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(
|
||||
CommandBehavior behavior, CancellationToken cancellationToken)
|
||||
{
|
||||
var occurredAtUtc = DateTime.UtcNow;
|
||||
var startTicks = Stopwatch.GetTimestamp();
|
||||
DbDataReader? reader = null;
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
reader = await _inner.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false);
|
||||
return new AuditingDbDataReader(
|
||||
reader,
|
||||
onClose: rows => EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: rows,
|
||||
thrown: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
EmitAudit(
|
||||
occurredAtUtc,
|
||||
ElapsedMs(startTicks),
|
||||
op: "read",
|
||||
rowsAffected: null,
|
||||
rowsReturned: null,
|
||||
thrown);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// -- Emission ---------------------------------------------------------
|
||||
|
||||
private static int ElapsedMs(long startTicks) =>
|
||||
(int)((Stopwatch.GetTimestamp() - startTicks) * 1000d / Stopwatch.Frequency);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort emission of one <c>DbOutbound</c>/<c>DbWrite</c> audit row.
|
||||
/// Mirrors the M2 Bundle F <c>EmitCallAudit</c> 3-layer fail-safe pattern.
|
||||
/// </summary>
|
||||
private void EmitAudit(
|
||||
DateTime occurredAtUtc,
|
||||
int durationMs,
|
||||
string op,
|
||||
int? rowsAffected,
|
||||
int? rowsReturned,
|
||||
Exception? thrown)
|
||||
{
|
||||
AuditEvent evt;
|
||||
try
|
||||
{
|
||||
evt = BuildAuditEvent(occurredAtUtc, durationMs, op, rowsAffected, rowsReturned, thrown);
|
||||
}
|
||||
catch (Exception buildEx)
|
||||
{
|
||||
// Defensive: building the event from already-validated fields
|
||||
// shouldn't throw, but the alog.md §7 contract requires we never
|
||||
// propagate to the user-facing action regardless.
|
||||
_logger.LogWarning(buildEx,
|
||||
"Failed to build Audit Log #23 event for {Connection} (op={Op}) — skipping emission",
|
||||
_connectionName, op);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var writeTask = _auditWriter.WriteAsync(evt, CancellationToken.None);
|
||||
if (!writeTask.IsCompleted)
|
||||
{
|
||||
writeTask.ContinueWith(
|
||||
t => _logger.LogWarning(t.Exception,
|
||||
"Audit Log #23 write failed for EventId {EventId} ({Connection} op={Op})",
|
||||
evt.EventId, _connectionName, op),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
else if (writeTask.IsFaulted)
|
||||
{
|
||||
_logger.LogWarning(writeTask.Exception,
|
||||
"Audit Log #23 write failed for EventId {EventId} ({Connection} op={Op})",
|
||||
evt.EventId, _connectionName, op);
|
||||
}
|
||||
}
|
||||
catch (Exception writeEx)
|
||||
{
|
||||
// Synchronous throw from WriteAsync before its own try/catch.
|
||||
// Swallow + log per alog.md §7.
|
||||
_logger.LogWarning(writeEx,
|
||||
"Audit Log #23 write threw synchronously for EventId {EventId} ({Connection} op={Op})",
|
||||
evt.EventId, _connectionName, op);
|
||||
}
|
||||
}
|
||||
|
||||
private AuditEvent BuildAuditEvent(
|
||||
DateTime occurredAtUtc,
|
||||
int durationMs,
|
||||
string op,
|
||||
int? rowsAffected,
|
||||
int? rowsReturned,
|
||||
Exception? thrown)
|
||||
{
|
||||
var status = thrown == null ? AuditStatus.Delivered : AuditStatus.Failed;
|
||||
|
||||
// Target = "<connectionName>.<first 60 chars of SQL>" so the audit
|
||||
// row carries a human-recognisable handle without dragging the full
|
||||
// (potentially huge) statement into the index column. The full
|
||||
// statement + parameter values live in RequestSummary.
|
||||
string target = _connectionName;
|
||||
var sqlSnippet = _inner.CommandText ?? string.Empty;
|
||||
if (sqlSnippet.Length > 0)
|
||||
{
|
||||
var snippet = sqlSnippet.Length > 60
|
||||
? sqlSnippet[..60]
|
||||
: sqlSnippet;
|
||||
target = $"{_connectionName}.{snippet}";
|
||||
}
|
||||
|
||||
// RequestSummary captures the SQL statement + parameter values by
|
||||
// default per the alog.md M4 acceptance criteria (M5 will add
|
||||
// per-connection redaction opt-in).
|
||||
string? requestSummary = BuildRequestSummary();
|
||||
|
||||
// Extra carries the op discriminator + row count per the vocabulary
|
||||
// lock. Build as a small hand-rolled JSON object to avoid pulling
|
||||
// in System.Text.Json on the hot path.
|
||||
string extra = op == "write"
|
||||
? $"{{\"op\":\"write\",\"rowsAffected\":{(rowsAffected ?? 0)}}}"
|
||||
: $"{{\"op\":\"read\",\"rowsReturned\":{(rowsReturned ?? 0)}}}";
|
||||
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
Channel = AuditChannel.DbOutbound,
|
||||
Kind = AuditKind.DbWrite,
|
||||
// Audit Log #23: a sync one-shot DB write has no operation
|
||||
// lifecycle, so CorrelationId is null. ExecutionId carries the
|
||||
// per-execution id so this row shares an id with the other sync
|
||||
// trust-boundary rows from the same script run.
|
||||
CorrelationId = null,
|
||||
ExecutionId = _executionId,
|
||||
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
|
||||
// null for non-routed runs.
|
||||
ParentExecutionId = _parentExecutionId,
|
||||
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
|
||||
SourceInstanceId = _instanceName,
|
||||
SourceScript = _sourceScript,
|
||||
// Outbound channel: per the Audit Log Actor-column spec the actor is
|
||||
// the calling script. Null when no single script owns the call
|
||||
// (e.g. a shared script running inline).
|
||||
Actor = _sourceScript,
|
||||
Target = target,
|
||||
Status = status,
|
||||
HttpStatus = null,
|
||||
DurationMs = durationMs,
|
||||
ErrorMessage = thrown?.Message,
|
||||
ErrorDetail = thrown?.ToString(),
|
||||
RequestSummary = requestSummary,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = extra,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose a JSON request summary capturing the SQL statement and
|
||||
/// parameter values. Parameter values are captured by default per the
|
||||
/// M4 acceptance criteria — redaction is opt-in and deferred to M5.
|
||||
/// </summary>
|
||||
private string? BuildRequestSummary()
|
||||
{
|
||||
var sql = _inner.CommandText;
|
||||
if (string.IsNullOrEmpty(sql))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hand-roll the JSON so we don't pull in System.Text.Json for a
|
||||
// shape this small. Values are stringified with ToString() — fully
|
||||
// structured serialisation arrives with the redaction work in M5.
|
||||
var sb = new System.Text.StringBuilder(sql.Length + 64);
|
||||
sb.Append("{\"sql\":");
|
||||
AppendJsonString(sb, sql);
|
||||
|
||||
if (_inner.Parameters.Count > 0)
|
||||
{
|
||||
sb.Append(",\"parameters\":{");
|
||||
var first = true;
|
||||
foreach (DbParameter p in _inner.Parameters)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
AppendJsonString(sb, p.ParameterName);
|
||||
sb.Append(':');
|
||||
if (p.Value is null || p.Value is DBNull)
|
||||
{
|
||||
sb.Append("null");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendJsonString(sb, p.Value.ToString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendJsonString(System.Text.StringBuilder sb, string value)
|
||||
{
|
||||
sb.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\b': sb.Append("\\b"); break;
|
||||
case '\f': sb.Append("\\f"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (ch < 0x20)
|
||||
{
|
||||
sb.Append("\\u").Append(((int)ch).ToString("x4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(ch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A: thin decorator over the
|
||||
/// <see cref="DbConnection"/> returned by
|
||||
/// <see cref="ScriptRuntimeContext.DatabaseHelper.Connection"/>. The decorator
|
||||
/// itself does no audit work — it simply intercepts
|
||||
/// <see cref="CreateDbCommand"/> so the <see cref="DbCommand"/> handed back to
|
||||
/// the script is wrapped in an <see cref="AuditingDbCommand"/> that emits one
|
||||
/// <c>DbOutbound</c>/<c>DbWrite</c> audit row per execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All other <see cref="DbConnection"/> members forward to the inner connection
|
||||
/// unchanged so the script keeps full ADO.NET semantics (transactions, state
|
||||
/// transitions, server-version queries, etc.). Disposing the wrapper disposes
|
||||
/// the inner connection — the caller is still responsible for disposal per
|
||||
/// the <see cref="IDatabaseGateway"/> contract.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The audit-write failure contract (alog.md §7) is honoured at the
|
||||
/// <see cref="AuditingDbCommand"/> layer — see that class for the 3-layer
|
||||
/// fail-safe pattern (build, write, observe).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditingDbConnection : DbConnection
|
||||
{
|
||||
private readonly DbConnection _inner;
|
||||
private readonly IAuditWriter _auditWriter;
|
||||
private readonly string _connectionName;
|
||||
private readonly string _siteId;
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
|
||||
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
|
||||
/// alongside <see cref="_executionId"/> into the
|
||||
/// <see cref="AuditingDbCommand"/> so its <c>DbWrite</c> row stamps it.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new auditing database connection decorator.
|
||||
/// </summary>
|
||||
/// <param name="inner">The inner database connection to wrap and audit commands through.</param>
|
||||
/// <param name="auditWriter">Writer for audit log entries.</param>
|
||||
/// <param name="connectionName">Name of the database connection being used.</param>
|
||||
/// <param name="siteId">Identifier of the site executing commands.</param>
|
||||
/// <param name="instanceName">Unique name of the instance executing commands.</param>
|
||||
/// <param name="sourceScript">Optional name of the source script for audit trail.</param>
|
||||
/// <param name="logger">Logger for diagnostics and warnings.</param>
|
||||
/// <param name="executionId">Unique identifier for this script execution.</param>
|
||||
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
|
||||
// Parameter ordering: executionId sits immediately after the ILogger,
|
||||
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
|
||||
// DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing
|
||||
// optional param so existing positional callers stay source-compatible.
|
||||
public AuditingDbConnection(
|
||||
DbConnection inner,
|
||||
IAuditWriter auditWriter,
|
||||
string connectionName,
|
||||
string siteId,
|
||||
string instanceName,
|
||||
string? sourceScript,
|
||||
ILogger logger,
|
||||
Guid executionId,
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||
_connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
|
||||
_siteId = siteId ?? string.Empty;
|
||||
_instanceName = instanceName ?? string.Empty;
|
||||
_sourceScript = sourceScript;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_executionId = executionId;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-022: exposes the wrapped <see cref="DbConnection"/> to the
|
||||
/// sibling <see cref="AuditingDbCommand"/> in the same assembly, so the
|
||||
/// command's <c>DbConnection</c> setter can unwrap an
|
||||
/// <see cref="AuditingDbConnection"/> without reflecting into the
|
||||
/// private <c>_inner</c> field. Both classes are <c>internal sealed</c>
|
||||
/// in this assembly, so the accessor stays out of the public API and
|
||||
/// matches the SiteRuntime-006 precedent of preferring proper API surface
|
||||
/// over <see cref="System.Reflection"/>.
|
||||
/// </summary>
|
||||
internal DbConnection Inner => _inner;
|
||||
|
||||
/// <inheritdoc />
|
||||
// ConnectionString is settable on DbConnection — forward both halves.
|
||||
public override string ConnectionString
|
||||
{
|
||||
// Some providers throw on get when the connection hasn't been opened
|
||||
// with a string set explicitly. The wrapper has no opinion — forward.
|
||||
#pragma warning disable CS8765 // nullability of overridden member parameter — base setter accepts null in practice
|
||||
get => _inner.ConnectionString;
|
||||
set => _inner.ConnectionString = value;
|
||||
#pragma warning restore CS8765
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Database => _inner.Database;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string DataSource => _inner.DataSource;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ServerVersion => _inner.ServerVersion;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ConnectionState State => _inner.State;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void ChangeDatabase(string databaseName) => _inner.ChangeDatabase(databaseName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Close() => _inner.Close();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Open() => _inner.Open();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task OpenAsync(CancellationToken cancellationToken) => _inner.OpenAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
|
||||
=> _inner.BeginTransaction(isolationLevel);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override DbCommand CreateDbCommand()
|
||||
{
|
||||
var innerCmd = _inner.CreateCommand();
|
||||
// Hand the script an auditing wrapper. The wrapper preserves the
|
||||
// inner command's identity for parameters / type maps via delegation.
|
||||
return new AuditingDbCommand(
|
||||
innerCmd,
|
||||
_auditWriter,
|
||||
_connectionName,
|
||||
_siteId,
|
||||
_instanceName,
|
||||
_sourceScript,
|
||||
_logger,
|
||||
_executionId,
|
||||
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
|
||||
// threaded alongside _executionId. Null for non-routed runs.
|
||||
_parentExecutionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask DisposeAsync()
|
||||
{
|
||||
// DbConnection.DisposeAsync is virtual; calling base would run the
|
||||
// synchronous Dispose path. Forward to the inner connection
|
||||
// asynchronously and short-circuit the base.
|
||||
var task = _inner.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Collections;
|
||||
using System.Data.Common;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — M4 Bundle A: <see cref="DbDataReader"/> decorator that
|
||||
/// counts the number of rows read by the script and fires a single audit
|
||||
/// emission callback when the reader closes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The wrapping reader counts each successful <see cref="Read"/> /
|
||||
/// <see cref="ReadAsync(CancellationToken)"/> and invokes <c>onClose</c>
|
||||
/// exactly once — on <see cref="Close"/>, <see cref="CloseAsync"/>, or
|
||||
/// disposal — with the running tally. This lets
|
||||
/// <see cref="AuditingDbCommand"/> emit one
|
||||
/// <c>DbOutbound</c>/<c>DbWrite</c> row per <c>ExecuteReader</c> with
|
||||
/// <c>Extra.rowsReturned</c> populated, matching the M4 vocabulary lock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Multiple result sets via <see cref="NextResult"/> are folded into a single
|
||||
/// <c>rowsReturned</c> tally — the script sees one audit row per
|
||||
/// <c>ExecuteReader</c> call, not per result set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AuditingDbDataReader : DbDataReader
|
||||
{
|
||||
private readonly DbDataReader _inner;
|
||||
private readonly Action<int> _onClose;
|
||||
private int _rowsReturned;
|
||||
private bool _closed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuditingDbDataReader"/> class, wrapping a data reader to count rows read.
|
||||
/// </summary>
|
||||
/// <param name="inner">The underlying DbDataReader to wrap and audit.</param>
|
||||
/// <param name="onClose">Callback invoked once when the reader closes, receiving the total rows read.</param>
|
||||
public AuditingDbDataReader(DbDataReader inner, Action<int> onClose)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_onClose = onClose ?? throw new ArgumentNullException(nameof(onClose));
|
||||
}
|
||||
|
||||
// -- Row-count interception ------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Read()
|
||||
{
|
||||
var more = _inner.Read();
|
||||
if (more) _rowsReturned++;
|
||||
return more;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<bool> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var more = await _inner.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (more) _rowsReturned++;
|
||||
return more;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Close()
|
||||
{
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { _inner.Close(); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task CloseAsync()
|
||||
{
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { await _inner.CloseAsync().ConfigureAwait(false); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// DbDataReader.Dispose calls Close on most providers, but we
|
||||
// guard with _closed to ensure onClose fires exactly once.
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { _inner.Dispose(); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
_inner.Dispose();
|
||||
}
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_closed)
|
||||
{
|
||||
_closed = true;
|
||||
try { await _inner.DisposeAsync().ConfigureAwait(false); }
|
||||
finally { SafeFireOnClose(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
await _inner.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void SafeFireOnClose()
|
||||
{
|
||||
// The onClose callback runs the audit emission, which is itself
|
||||
// best-effort and swallows internally — but defend the reader's own
|
||||
// close path anyway so an audit fault never propagates out of
|
||||
// Close/Dispose.
|
||||
try { _onClose(_rowsReturned); }
|
||||
catch { /* audit emission is best-effort by contract */ }
|
||||
}
|
||||
|
||||
// -- Forwarded surface ------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object this[int ordinal] => _inner[ordinal];
|
||||
/// <inheritdoc />
|
||||
public override object this[string name] => _inner[name];
|
||||
/// <inheritdoc />
|
||||
public override int Depth => _inner.Depth;
|
||||
/// <inheritdoc />
|
||||
public override int FieldCount => _inner.FieldCount;
|
||||
/// <inheritdoc />
|
||||
public override bool HasRows => _inner.HasRows;
|
||||
/// <inheritdoc />
|
||||
public override bool IsClosed => _inner.IsClosed;
|
||||
/// <inheritdoc />
|
||||
public override int RecordsAffected => _inner.RecordsAffected;
|
||||
/// <inheritdoc />
|
||||
public override int VisibleFieldCount => _inner.VisibleFieldCount;
|
||||
/// <inheritdoc />
|
||||
public override bool GetBoolean(int ordinal) => _inner.GetBoolean(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override byte GetByte(int ordinal) => _inner.GetByte(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
|
||||
=> _inner.GetBytes(ordinal, dataOffset, buffer, bufferOffset, length);
|
||||
/// <inheritdoc />
|
||||
public override char GetChar(int ordinal) => _inner.GetChar(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
|
||||
=> _inner.GetChars(ordinal, dataOffset, buffer, bufferOffset, length);
|
||||
/// <inheritdoc />
|
||||
public override string GetDataTypeName(int ordinal) => _inner.GetDataTypeName(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override DateTime GetDateTime(int ordinal) => _inner.GetDateTime(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override decimal GetDecimal(int ordinal) => _inner.GetDecimal(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override double GetDouble(int ordinal) => _inner.GetDouble(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override IEnumerator GetEnumerator() => ((IEnumerable)_inner).GetEnumerator();
|
||||
/// <inheritdoc />
|
||||
public override Type GetFieldType(int ordinal) => _inner.GetFieldType(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override float GetFloat(int ordinal) => _inner.GetFloat(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override Guid GetGuid(int ordinal) => _inner.GetGuid(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override short GetInt16(int ordinal) => _inner.GetInt16(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override int GetInt32(int ordinal) => _inner.GetInt32(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override long GetInt64(int ordinal) => _inner.GetInt64(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override string GetName(int ordinal) => _inner.GetName(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override int GetOrdinal(string name) => _inner.GetOrdinal(name);
|
||||
/// <inheritdoc />
|
||||
public override string GetString(int ordinal) => _inner.GetString(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override object GetValue(int ordinal) => _inner.GetValue(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override int GetValues(object[] values) => _inner.GetValues(values);
|
||||
/// <inheritdoc />
|
||||
public override bool IsDBNull(int ordinal) => _inner.IsDBNull(ordinal);
|
||||
/// <inheritdoc />
|
||||
public override bool NextResult() => _inner.NextResult();
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> NextResultAsync(CancellationToken cancellationToken) => _inner.NextResultAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
|
||||
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
|
||||
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
|
||||
///
|
||||
/// <para>
|
||||
/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously
|
||||
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
|
||||
/// round-trip). This is safe because script bodies execute on the dedicated
|
||||
/// <see cref="ScriptExecutionScheduler"/> threads (SiteRuntime-009), not the
|
||||
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
|
||||
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
|
||||
/// variants (<see cref="GetAsync"/>/<see cref="SetAsync"/>) are still preferred
|
||||
/// where the script can await, as they avoid holding a dedicated thread idle for
|
||||
/// the duration of each round-trip.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AttributeAccessor
|
||||
{
|
||||
private readonly ScriptRuntimeContext _ctx;
|
||||
|
||||
/// <summary>Canonical-name prefix, e.g. "" for root or "TempSensor" for a composition.</summary>
|
||||
public string ScopePrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AttributeAccessor with the specified context and prefix.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="prefix">The canonical-name prefix for attribute resolution.</param>
|
||||
public AttributeAccessor(ScriptRuntimeContext ctx, string prefix)
|
||||
{
|
||||
_ctx = ctx;
|
||||
ScopePrefix = prefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a key to its full canonical name by applying the scope prefix.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key to resolve.</param>
|
||||
public string Resolve(string key) =>
|
||||
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an attribute value synchronously by canonical name.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key.</param>
|
||||
public object? this[string key]
|
||||
{
|
||||
// Both reads and writes block on the actor Ask; the write also blocks
|
||||
// on the DCL round-trip for data-connected attributes. The async
|
||||
// variants (GetAsync/SetAsync) are preferred where awaiting is possible.
|
||||
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
|
||||
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attribute value asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key.</param>
|
||||
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
|
||||
|
||||
/// <summary>
|
||||
/// Sets an attribute value asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="key">The attribute key.</param>
|
||||
/// <param name="value">The value to set.</param>
|
||||
public Task SetAsync(string key, object? value)
|
||||
=> _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A view of one composition at a path. Exposes its attributes via
|
||||
/// <see cref="AttributeAccessor"/> and an invokable <c>CallScript</c>.
|
||||
/// </summary>
|
||||
public class CompositionAccessor
|
||||
{
|
||||
private readonly ScriptRuntimeContext _ctx;
|
||||
|
||||
/// <summary>Canonical-name path this composition is rooted at.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Accessor for attributes within this composition.
|
||||
/// </summary>
|
||||
public AttributeAccessor Attributes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the CompositionAccessor with the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="path">The canonical-name path for the composition.</param>
|
||||
public CompositionAccessor(ScriptRuntimeContext ctx, string path)
|
||||
{
|
||||
_ctx = ctx;
|
||||
Path = path;
|
||||
Attributes = new AttributeAccessor(ctx, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a script name to its full canonical name by applying the composition path.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The script name to resolve.</param>
|
||||
public string ResolveScript(string scriptName) =>
|
||||
Path.Length == 0 ? scriptName : Path + "." + scriptName;
|
||||
|
||||
/// <summary>
|
||||
/// Calls a script within this composition.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The name of the script to call.</param>
|
||||
/// <param name="parameters">Optional parameters to pass to the script.</param>
|
||||
public Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary-style accessor for the script's child compositions. Indexing
|
||||
/// returns a <see cref="CompositionAccessor"/> rooted at the child's path.
|
||||
/// </summary>
|
||||
public class ChildrenAccessor
|
||||
{
|
||||
private readonly ScriptRuntimeContext _ctx;
|
||||
private readonly string _selfPath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ChildrenAccessor with the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="selfPath">The canonical-name path of the parent composition.</param>
|
||||
public ChildrenAccessor(ScriptRuntimeContext ctx, string selfPath)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_selfPath = selfPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a composition accessor for a child by name.
|
||||
/// </summary>
|
||||
/// <param name="compositionName">The name of the child composition.</param>
|
||||
public CompositionAccessor this[string compositionName]
|
||||
{
|
||||
get
|
||||
{
|
||||
var path = _selfPath.Length == 0
|
||||
? compositionName
|
||||
: _selfPath + "." + compositionName;
|
||||
return new CompositionAccessor(_ctx, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ScopeAccessorFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AttributeAccessor for the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="selfPath">The canonical-name path.</param>
|
||||
public static AttributeAccessor AttributesFor(ScriptRuntimeContext ctx, string selfPath)
|
||||
=> new(ctx, selfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ChildrenAccessor for the specified context and path.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="selfPath">The canonical-name path.</param>
|
||||
public static ChildrenAccessor ChildrenFor(ScriptRuntimeContext ctx, string selfPath)
|
||||
=> new(ctx, selfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CompositionAccessor for the specified context and path, or null if no parent exists.
|
||||
/// </summary>
|
||||
/// <param name="ctx">The script runtime context.</param>
|
||||
/// <param name="parentPath">The parent path, or null if no parent.</param>
|
||||
public static CompositionAccessor? ParentFor(ScriptRuntimeContext ctx, string? parentPath)
|
||||
=> parentPath == null ? null : new CompositionAccessor(ctx, parentPath);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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>
|
||||
/// Forbidden API roots. Each entry is matched as a prefix against both the resolved
|
||||
/// symbol's containing namespace and its fully-qualified containing type name, so an
|
||||
/// entry may name a whole namespace ("System.IO") or a single type
|
||||
/// ("System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
private static readonly string[] ForbiddenNamespaces =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading",
|
||||
"System.Reflection",
|
||||
"System.Net.Sockets",
|
||||
"System.Net.Http"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Specific namespaces/types allowed even though they sit under a forbidden root.
|
||||
/// async/await and cancellation tokens are OK despite System.Threading being blocked.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExceptions =
|
||||
[
|
||||
"System.Threading.Tasks",
|
||||
"System.Threading.CancellationToken",
|
||||
"System.Threading.CancellationTokenSource"
|
||||
];
|
||||
|
||||
/// <summary>Initializes a new instance of the ScriptCompilationService class.</summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-011: validates that the script does not reference forbidden APIs.
|
||||
///
|
||||
/// Validation is performed with Roslyn semantic analysis rather than a raw substring
|
||||
/// scan of the source text. The script is parsed and a semantic model is built; every
|
||||
/// identifier, type reference, member access, and object creation is resolved to its
|
||||
/// symbol and the symbol's containing namespace is checked against the forbidden list.
|
||||
///
|
||||
/// This is reliable in both directions a textual scan was not:
|
||||
/// - it catches forbidden types regardless of how they are written (<c>global::</c>
|
||||
/// prefixes, aliases, transitively-imported namespaces) because it inspects the
|
||||
/// resolved symbol, not the spelling;
|
||||
/// - it does not raise false positives for the namespace string appearing in a
|
||||
/// comment, a string literal, or an unrelated identifier.
|
||||
///
|
||||
/// Returns a list of violation messages, empty if clean.
|
||||
/// </summary>
|
||||
/// <param name="code">The script code to validate.</param>
|
||||
public IReadOnlyList<string> ValidateTrustModel(string code)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(
|
||||
code, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
|
||||
var compilation = CSharpCompilation.CreateScriptCompilation(
|
||||
"TrustValidation",
|
||||
tree,
|
||||
ScriptReferences,
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
var root = tree.GetRoot();
|
||||
|
||||
// Deduplicate so a forbidden symbol used many times is reported once but
|
||||
// distinct forbidden symbols are all reported.
|
||||
var violations = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var node in root.DescendantNodes())
|
||||
{
|
||||
// Only inspect nodes that name a type or member; skip declarations,
|
||||
// string literals and comments entirely. Member-access and qualified-name
|
||||
// parents are evaluated as a whole, so their nested name parts are skipped.
|
||||
if (node is not (SimpleNameSyntax or MemberAccessExpressionSyntax
|
||||
or QualifiedNameSyntax or ObjectCreationExpressionSyntax))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = model.GetSymbolInfo(node);
|
||||
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||
|
||||
// The set of fully-qualified scopes this reference touches: the resolved
|
||||
// symbol's containing namespace and type, or — when the symbol could not
|
||||
// be resolved (a type from an unreferenced assembly) — the syntactic
|
||||
// fully-qualified name written in source as a safe fallback.
|
||||
var scopes = symbol != null
|
||||
? GetSymbolScopes(symbol)
|
||||
: GetSyntacticScopes(node);
|
||||
if (scopes.Count == 0)
|
||||
continue;
|
||||
|
||||
var forbidden = ForbiddenNamespaces.FirstOrDefault(
|
||||
f => scopes.Any(s => IsUnderScope(s, f)));
|
||||
if (forbidden == null)
|
||||
continue;
|
||||
|
||||
// Allow specific exception namespaces/types (async/await, cancellation).
|
||||
if (scopes.Any(s => AllowedExceptions.Any(a => IsUnderScope(s, a))))
|
||||
continue;
|
||||
|
||||
var name = symbol?.Name ?? node.ToString();
|
||||
violations.Add($"Forbidden API reference: '{forbidden}' ({scopes[0]}.{name})");
|
||||
}
|
||||
|
||||
return violations.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the fully-qualified scopes a resolved symbol belongs to — its containing
|
||||
/// namespace and, for a type or member, the fully-qualified containing type. A bare
|
||||
/// namespace symbol is intentionally ignored: a namespace name on its own performs
|
||||
/// no action; harm requires referencing a type or a member.
|
||||
/// </summary>
|
||||
private static List<string> GetSymbolScopes(ISymbol symbol)
|
||||
{
|
||||
var scopes = new List<string>();
|
||||
|
||||
switch (symbol)
|
||||
{
|
||||
case INamespaceSymbol:
|
||||
// A namespace reference alone is harmless — skip it. (This avoids a
|
||||
// false positive on the "System.Threading" qualifier of the allowed
|
||||
// "System.Threading.Tasks.Task".)
|
||||
break;
|
||||
case ITypeSymbol typeSymbol:
|
||||
scopes.Add(typeSymbol.ToDisplayString());
|
||||
if (typeSymbol.ContainingNamespace is { IsGlobalNamespace: false } typeNs)
|
||||
scopes.Add(typeNs.ToDisplayString());
|
||||
break;
|
||||
default:
|
||||
if (symbol.ContainingType != null)
|
||||
{
|
||||
scopes.Add(symbol.ContainingType.ToDisplayString());
|
||||
if (symbol.ContainingType.ContainingNamespace is { IsGlobalNamespace: false } memberNs)
|
||||
scopes.Add(memberNs.ToDisplayString());
|
||||
}
|
||||
else if (symbol.ContainingNamespace is { IsGlobalNamespace: false } ns)
|
||||
{
|
||||
scopes.Add(ns.ToDisplayString());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback used when a name could not be resolved to a symbol (e.g. a type from an
|
||||
/// assembly the script is not allowed to reference). The fully-qualified name as
|
||||
/// written in source is used directly — a script that names
|
||||
/// <c>System.Net.Http.HttpClient</c> is still rejected even though that assembly is
|
||||
/// deliberately absent from the script's metadata references.
|
||||
/// </summary>
|
||||
private static List<string> GetSyntacticScopes(SyntaxNode node)
|
||||
{
|
||||
// A dotted name written in source is itself the fully-qualified scope. Only
|
||||
// consider names that actually contain a dot — bare local identifiers cannot
|
||||
// reach a forbidden namespace.
|
||||
var text = node switch
|
||||
{
|
||||
QualifiedNameSyntax q => q.ToString(),
|
||||
MemberAccessExpressionSyntax m => m.ToString(),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
// Strip whitespace/newlines that a multi-line member-access chain may contain.
|
||||
text = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
|
||||
return string.IsNullOrEmpty(text) || !text.Contains('.')
|
||||
? []
|
||||
: [text];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="actual"/> is exactly, or nested within,
|
||||
/// <paramref name="root"/> (e.g. "System.IO.Compression" is under "System.IO",
|
||||
/// "System.Diagnostics.Process" is under "System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
private static bool IsUnderScope(string actual, string root)
|
||||
=> actual.Equals(root, StringComparison.Ordinal)
|
||||
|| actual.StartsWith(root + ".", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Assemblies referenced by compiled scripts. Shared between the Roslyn scripting
|
||||
/// options and the semantic-analysis compilation built for trust validation
|
||||
/// (SiteRuntime-011), so the validator resolves symbols against exactly the same
|
||||
/// metadata the script is compiled against.
|
||||
/// </summary>
|
||||
private static readonly System.Reflection.Assembly[] ScriptAssemblies =
|
||||
[
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Math).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(Commons.Types.DynamicJsonElement).Assembly
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Metadata references for the trust-validation semantic compilation.
|
||||
/// </summary>
|
||||
private static readonly MetadataReference[] ScriptReferences =
|
||||
ScriptAssemblies
|
||||
.Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Shared Roslyn scripting options (references + imports) used by both full
|
||||
/// script compilation and trigger-expression compilation.
|
||||
/// </summary>
|
||||
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
|
||||
.WithReferences(ScriptAssemblies)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
/// and parameters dictionary, and returns an object? result.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The name of the script.</param>
|
||||
/// <param name="code">The script code to compile.</param>
|
||||
public ScriptCompilationResult Compile(string scriptName, string code)
|
||||
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a bare C# boolean trigger expression against the restricted
|
||||
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
|
||||
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
|
||||
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
|
||||
/// options and forbidden-API trust validation as <see cref="Compile"/>.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the trigger expression.</param>
|
||||
/// <param name="expression">The trigger expression to compile.</param>
|
||||
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
|
||||
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
|
||||
|
||||
/// <summary>
|
||||
/// Shared compilation path: validates the trust model, builds the script
|
||||
/// against the given globals type, and returns the compiled result.
|
||||
/// </summary>
|
||||
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
|
||||
{
|
||||
// Validate trust model
|
||||
var violations = ValidateTrustModel(code);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} failed trust validation: {Violations}",
|
||||
name, string.Join("; ", violations));
|
||||
return ScriptCompilationResult.Failed(violations);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var script = CSharpScript.Create<object?>(
|
||||
code,
|
||||
BuildScriptOptions(),
|
||||
globalsType: globalsType);
|
||||
|
||||
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}",
|
||||
name, string.Join("; ", errors));
|
||||
return ScriptCompilationResult.Failed(errors);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Script {Script} compiled successfully", name);
|
||||
return ScriptCompilationResult.Succeeded(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
|
||||
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of script compilation, containing either the compiled script or error messages.
|
||||
/// </summary>
|
||||
public class ScriptCompilationResult
|
||||
{
|
||||
/// <summary>Indicates whether compilation succeeded.</summary>
|
||||
public bool IsSuccess { get; }
|
||||
/// <summary>The compiled script, or null if compilation failed.</summary>
|
||||
public Script<object?>? CompiledScript { get; }
|
||||
/// <summary>List of error messages, empty if compilation succeeded.</summary>
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
|
||||
{
|
||||
IsSuccess = success;
|
||||
CompiledScript = script;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
/// <summary>Creates a successful compilation result.</summary>
|
||||
/// <param name="script">The compiled script.</param>
|
||||
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
|
||||
new(true, script, []);
|
||||
|
||||
/// <summary>Creates a failed compilation result.</summary>
|
||||
/// <param name="errors">List of error messages.</param>
|
||||
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
|
||||
{
|
||||
/// <summary>The script runtime context providing access to instance state.</summary>
|
||||
public ScriptRuntimeContext Instance { get; set; } = null!;
|
||||
/// <summary>Script parameters passed by the caller.</summary>
|
||||
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
||||
/// <summary>Cancellation token for script execution.</summary>
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context when this script is invoked as an on-trigger handler.
|
||||
/// Null for instance scripts, shared scripts, and inbound-API-routed
|
||||
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
|
||||
/// (HiLo only), Priority, and per-band Message to branch routing logic.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where this script sits in the composition tree. Defaults to root for
|
||||
/// scripts on top-level templates; a flattened composed script gets
|
||||
/// SelfPath = "TempSensor" (etc.) and a ParentPath set to one level up.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.ScriptScope Scope { get; set; } =
|
||||
Commons.Types.Scripts.ScriptScope.Root;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
||||
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Database access for scripts (delegates to Instance.Database).
|
||||
/// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Notify access for scripts (delegates to Instance.Notify).
|
||||
/// Usage: Notify.To("listName").Send("subject", "message")
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Scripts access for shared script calls (delegates to Instance.Scripts).
|
||||
/// Usage: Scripts.CallShared("scriptName", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Read/write the current template's attributes by name. Resolves to the
|
||||
/// canonical name for the script's scope, so a script on a composed
|
||||
/// TempSensor reads its own Temperature via <c>Attributes["Temperature"]</c>.
|
||||
/// </summary>
|
||||
public AttributeAccessor Attributes => new(Instance, Scope.SelfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Indexed access to child compositions.
|
||||
/// <c>Children["TempSensor"].Attributes["Temperature"]</c> reads the
|
||||
/// composed child's attribute. <c>Children["TempSensor"].CallScript("Sample")</c>
|
||||
/// invokes a script on the child.
|
||||
/// </summary>
|
||||
public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null when this script is on a root-level template).
|
||||
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
||||
/// <c>Parent.CallScript("Trip")</c> invokes a parent script.
|
||||
/// </summary>
|
||||
public CompositionAccessor? Parent =>
|
||||
Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-009: a dedicated, bounded <see cref="TaskScheduler"/> for running script
|
||||
/// and alarm on-trigger bodies.
|
||||
///
|
||||
/// Script bodies may perform synchronous blocking I/O (a database connection, a
|
||||
/// synchronous external-system call). Running them on the shared .NET
|
||||
/// <see cref="ThreadPool"/> lets a burst of blocking scripts starve the pool and stall
|
||||
/// unrelated Akka dispatchers and HTTP request handling. This scheduler owns a fixed set
|
||||
/// of dedicated threads, so script blocking is contained to those threads and cannot
|
||||
/// exhaust the global pool.
|
||||
///
|
||||
/// The scheduler is process-wide (one set of threads for all instances) and is sized
|
||||
/// from <see cref="SiteRuntimeOptions"/> the first time it is configured.
|
||||
/// </summary>
|
||||
public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
|
||||
{
|
||||
private readonly BlockingCollection<Task> _queue = new();
|
||||
private readonly List<Thread> _threads;
|
||||
private int _disposed;
|
||||
|
||||
private static volatile ScriptExecutionScheduler? _shared;
|
||||
private static readonly object SharedLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// The process-wide script-execution scheduler. Lazily created on first use with the
|
||||
/// thread count from <see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/>; the
|
||||
/// first caller wins, subsequent calls reuse the existing instance.
|
||||
/// </summary>
|
||||
/// <param name="options">Site runtime options supplying the thread count for the scheduler.</param>
|
||||
public static ScriptExecutionScheduler Shared(SiteRuntimeOptions options)
|
||||
{
|
||||
if (_shared != null)
|
||||
return _shared;
|
||||
|
||||
lock (SharedLock)
|
||||
{
|
||||
return _shared ??= new ScriptExecutionScheduler(options.ScriptExecutionThreadCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scheduler backed by <paramref name="threadCount"/> dedicated threads.
|
||||
/// </summary>
|
||||
/// <param name="threadCount">Number of dedicated worker threads to create.</param>
|
||||
public ScriptExecutionScheduler(int threadCount)
|
||||
{
|
||||
if (threadCount < 1)
|
||||
threadCount = 1;
|
||||
|
||||
_threads = new List<Thread>(threadCount);
|
||||
for (var i = 0; i < threadCount; i++)
|
||||
{
|
||||
var thread = new Thread(WorkerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"script-execution-{i}"
|
||||
};
|
||||
_threads.Add(thread);
|
||||
thread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int MaximumConcurrencyLevel => _threads.Count;
|
||||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var task in _queue.GetConsumingEnumerable())
|
||||
{
|
||||
TryExecuteTask(task);
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Scheduler disposed — worker exits.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void QueueTask(Task task) => _queue.Add(task);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
|
||||
{
|
||||
// Only inline if we are already on one of this scheduler's worker threads,
|
||||
// so script work never escapes onto a thread-pool thread.
|
||||
if (Thread.CurrentThread.Name?.StartsWith("script-execution-", StringComparison.Ordinal) != true)
|
||||
return false;
|
||||
|
||||
return TryExecuteTask(task);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<Task> GetScheduledTasks() => _queue.ToArray();
|
||||
|
||||
/// <summary>Signals the worker threads to stop and waits for them to drain, then disposes the queue.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
return;
|
||||
|
||||
_queue.CompleteAdding();
|
||||
foreach (var thread in _threads)
|
||||
thread.Join(TimeSpan.FromSeconds(5));
|
||||
_queue.Dispose();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SharedScriptLibrary"/> with the compilation service and logger.
|
||||
/// </summary>
|
||||
/// <param name="compilationService">Service used to compile Roslyn scripts.</param>
|
||||
/// <param name="logger">Logger for compilation warnings and diagnostics.</param>
|
||||
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>
|
||||
/// <param name="name">Unique name for the shared script.</param>
|
||||
/// <param name="code">C# source code of the shared script.</param>
|
||||
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>
|
||||
/// <param name="name">Name of the shared script to remove.</param>
|
||||
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>
|
||||
/// <param name="scriptName">Name of the shared script to execute.</param>
|
||||
/// <param name="context">Runtime context providing instance state and services to the script globals.</param>
|
||||
/// <param name="parameters">Optional input parameters passed to the script.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the execution.</param>
|
||||
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 = new ScriptParameters(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>
|
||||
/// <param name="name">Name of the shared script to look up.</param>
|
||||
public bool Contains(string name)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _compiledScripts.ContainsKey(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only globals a trigger expression is compiled against. Exposes only
|
||||
/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask,
|
||||
/// no side-effecting APIs. A missing attribute key reads as <c>null</c> and
|
||||
/// never throws.
|
||||
///
|
||||
/// Canonical attribute keys are dotted (e.g. "TempSensor.Reading"); the prefix
|
||||
/// logic here mirrors <see cref="AttributeAccessor.Resolve"/>.
|
||||
/// </summary>
|
||||
public sealed class TriggerExpressionGlobals
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the <c>"expression"</c> field from an Expression-trigger config
|
||||
/// JSON document. Returns <c>null</c> for a missing, blank, or malformed
|
||||
/// config — the single parsing idiom shared by InstanceActor, ScriptActor,
|
||||
/// and AlarmActor.
|
||||
/// </summary>
|
||||
/// <param name="triggerConfigJson">JSON string of the trigger configuration, or null.</param>
|
||||
public static string? ExtractExpression(string? triggerConfigJson)
|
||||
{
|
||||
if (string.IsNullOrEmpty(triggerConfigJson)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||||
var expr = doc.RootElement.TryGetProperty("expression", out var e)
|
||||
? e.GetString()
|
||||
: null;
|
||||
return string.IsNullOrWhiteSpace(expr) ? null : expr;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly IReadOnlyDictionary<string, object?> _snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the globals with a flat attribute snapshot.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Flat dictionary of canonical attribute key to current value.</param>
|
||||
public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot)
|
||||
=> _snapshot = snapshot;
|
||||
|
||||
/// <summary>Attributes in the expression's own scope (root prefix).</summary>
|
||||
public ReadOnlyAttributes Attributes => new(_snapshot, "");
|
||||
|
||||
/// <summary>Indexed access to child compositions' attributes.</summary>
|
||||
public ReadOnlyChildren Children => new(_snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null at root). Set by the caller for derived/composed
|
||||
/// scopes; the runtime actors evaluate at root scope, so this stays null.
|
||||
/// </summary>
|
||||
public ReadOnlyComposition? Parent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Read-only attribute view anchored at a canonical-name prefix. Indexing
|
||||
/// resolves to the canonical key ("" → key, "TempSensor" → "TempSensor.key").
|
||||
/// </summary>
|
||||
public sealed class ReadOnlyAttributes
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||
private readonly string _prefix;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a read-only attribute view anchored at the given prefix.
|
||||
/// </summary>
|
||||
/// <param name="s">The backing attribute snapshot.</param>
|
||||
/// <param name="prefix">Canonical name prefix; empty string for root scope.</param>
|
||||
public ReadOnlyAttributes(IReadOnlyDictionary<string, object?> s, string prefix)
|
||||
{
|
||||
_s = s;
|
||||
_prefix = prefix;
|
||||
}
|
||||
|
||||
/// <summary>Returns the attribute value for <paramref name="key"/>, or null if absent.</summary>
|
||||
/// <param name="key">The attribute key, relative to the prefix.</param>
|
||||
public object? this[string key] =>
|
||||
_s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
|
||||
}
|
||||
|
||||
/// <summary>A read-only view of one composition at a canonical-name path.</summary>
|
||||
public sealed class ReadOnlyComposition
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||
private readonly string _path;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a read-only composition view anchored at a canonical path.
|
||||
/// </summary>
|
||||
/// <param name="s">The backing attribute snapshot.</param>
|
||||
/// <param name="path">Canonical path prefix for this composition's attributes.</param>
|
||||
public ReadOnlyComposition(IReadOnlyDictionary<string, object?> s, string path)
|
||||
{
|
||||
_s = s;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
/// <summary>Read-only attribute view scoped to this composition's canonical path.</summary>
|
||||
public ReadOnlyAttributes Attributes => new(_s, _path);
|
||||
}
|
||||
|
||||
/// <summary>Dictionary-style accessor for child compositions.</summary>
|
||||
public sealed class ReadOnlyChildren
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, object?> _s;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a children accessor over the supplied attribute snapshot.
|
||||
/// </summary>
|
||||
/// <param name="s">The backing attribute snapshot.</param>
|
||||
public ReadOnlyChildren(IReadOnlyDictionary<string, object?> s) => _s = s;
|
||||
|
||||
/// <summary>Returns a read-only composition view for the named child composition.</summary>
|
||||
/// <param name="compositionName">The canonical composition name.</param>
|
||||
public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user