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,361 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-3: Executes the C# script associated with an inbound API method.
|
||||
/// Compiles method scripts via Roslyn and caches compiled delegates.
|
||||
/// </summary>
|
||||
public class InboundScriptExecutor
|
||||
{
|
||||
private readonly ILogger<InboundScriptExecutor> _logger;
|
||||
|
||||
// InboundAPI-001: this executor is registered as a singleton and its handler cache
|
||||
// is read and written from concurrent ASP.NET request threads. A plain Dictionary is
|
||||
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
|
||||
private readonly ConcurrentDictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
|
||||
|
||||
// InboundAPI-009: a script that fails to compile (or violates the trust model)
|
||||
// is recorded here so it is compiled at most once. Without this, every subsequent
|
||||
// request for a broken method re-runs the expensive Roslyn compilation — a CPU
|
||||
// amplification vector since the inbound API has no rate limiting. The entry is
|
||||
// cleared whenever the method is (re)compiled via CompileAndRegister.
|
||||
//
|
||||
// InboundAPI-024: bound the cache so a spam attack of unique method names cannot
|
||||
// grow it without bound. Once the cap is reached new bad-method records are
|
||||
// dropped — the cache is just a fast-fail optimisation; the per-request DB
|
||||
// lookup remains the correctness path.
|
||||
private const int KnownBadMethodsCap = 1000;
|
||||
private readonly ConcurrentDictionary<string, byte> _knownBadMethods = new();
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-024 diagnostic helper — returns the current size of the
|
||||
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
|
||||
/// so the cache itself stays an implementation detail.
|
||||
/// </summary>
|
||||
internal int KnownBadMethodCount => _knownBadMethods.Count;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-024: records <paramref name="methodName"/> in the known-bad-methods
|
||||
/// cache only if the cache has not reached <see cref="KnownBadMethodsCap"/>.
|
||||
/// Once full, new records are dropped (paying the cheap recompile next time
|
||||
/// rather than leaking memory under a unique-name flood). Existing entries are
|
||||
/// not touched — they remain capped fast-fail records until cleared on a
|
||||
/// successful (re)compile in <see cref="CompileAndRegister"/>.
|
||||
/// </summary>
|
||||
private void TryRecordBadMethod(string methodName)
|
||||
{
|
||||
if (_knownBadMethods.ContainsKey(methodName))
|
||||
return;
|
||||
if (_knownBadMethods.Count >= KnownBadMethodsCap)
|
||||
return;
|
||||
_knownBadMethods.TryAdd(methodName, 0);
|
||||
}
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the InboundScriptExecutor.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="serviceProvider">Service provider for dependency resolution.</param>
|
||||
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger, IServiceProvider serviceProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a compiled script handler for a method name.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The method name to register.</param>
|
||||
/// <param name="handler">The compiled handler function.</param>
|
||||
public void RegisterHandler(string methodName, Func<InboundScriptContext, Task<object?>> handler)
|
||||
{
|
||||
_scriptHandlers[methodName] = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a compiled script handler for a method name.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The method name to remove.</param>
|
||||
public void RemoveHandler(string methodName)
|
||||
{
|
||||
_scriptHandlers.TryRemove(methodName, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and registers a single API method script. Returns <c>false</c> if the
|
||||
/// script is empty, fails Roslyn compilation, or violates the script trust model.
|
||||
/// </summary>
|
||||
/// <param name="method">The API method to compile and register.</param>
|
||||
/// <returns>True if successfully compiled and registered, false otherwise.</returns>
|
||||
public bool CompileAndRegister(ApiMethod method)
|
||||
{
|
||||
var handler = Compile(method);
|
||||
if (handler == null)
|
||||
{
|
||||
// InboundAPI-009: record the failure so the lazy-compile path does not
|
||||
// keep recompiling a broken script on every request. InboundAPI-024:
|
||||
// routed through the capped TryRecordBadMethod helper so the cache
|
||||
// cannot grow without bound under a flood of unique method names.
|
||||
TryRecordBadMethod(method.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The method definition was (re)compiled successfully — drop any stale
|
||||
// failure record so a fixed script is no longer treated as bad.
|
||||
_knownBadMethods.TryRemove(method.Name, out _);
|
||||
return Register(method.Name, handler);
|
||||
}
|
||||
|
||||
private bool Register(string methodName, Func<InboundScriptContext, Task<object?>> handler)
|
||||
{
|
||||
_scriptHandlers[methodName] = handler;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a single API method script into an executable handler. Returns
|
||||
/// <c>null</c> when the script is missing, fails to compile, or violates the
|
||||
/// script trust model (InboundAPI-005). Does not mutate the handler cache.
|
||||
/// </summary>
|
||||
private Func<InboundScriptContext, Task<object?>>? Compile(ApiMethod method)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(method.Script))
|
||||
{
|
||||
_logger.LogWarning("API method {Method} has no script code", method.Name);
|
||||
return null;
|
||||
}
|
||||
|
||||
// InboundAPI-005: enforce the script trust model before compiling. Roslyn
|
||||
// scripting performs no API allow/deny-listing, so forbidden namespaces must
|
||||
// be rejected statically or the script could reach the host process.
|
||||
var violations = ForbiddenApiChecker.FindViolations(method.Script);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"API method {Method} script rejected — trust model violation(s): {Violations}",
|
||||
method.Name, string.Join("; ", violations));
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var scriptOptions = ScriptOptions.Default
|
||||
.WithReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Dictionary<,>).Assembly,
|
||||
typeof(RouteHelper).Assembly,
|
||||
typeof(ScriptParameters).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
var compiled = CSharpScript.Create<object?>(
|
||||
method.Script,
|
||||
scriptOptions,
|
||||
globalsType: typeof(InboundScriptContext));
|
||||
|
||||
var diagnostics = compiled.Compile();
|
||||
var errors = diagnostics
|
||||
.Where(d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"API method {Method} script compilation failed: {Errors}",
|
||||
method.Name, string.Join("; ", errors));
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("API method {Method} script compiled", method.Name);
|
||||
return async ctx =>
|
||||
{
|
||||
var state = await compiled.RunAsync(ctx, ctx.CancellationToken);
|
||||
return state.ReturnValue;
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to compile API method {Method} script", method.Name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the script for the given method with the provided context.
|
||||
/// </summary>
|
||||
/// <param name="method">The API method containing the script to execute.</param>
|
||||
/// <param name="parameters">Input parameters for the script.</param>
|
||||
/// <param name="route">Route helper for cross-site routing.</param>
|
||||
/// <param name="timeout">Timeout duration for script execution.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <param name="parentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
|
||||
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
|
||||
/// on <c>HttpContext.Items</c>). When supplied, a routed
|
||||
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
|
||||
/// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
|
||||
/// script execution points back at this inbound request. Null when the script
|
||||
/// runs outside an inbound API request flow.
|
||||
/// </param>
|
||||
/// <returns>The result of executing the script.</returns>
|
||||
public async Task<InboundScriptResult> ExecuteAsync(
|
||||
ApiMethod method,
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
RouteHelper route,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default,
|
||||
// Deliberate ordering: this optional parameter trails the CancellationToken
|
||||
// because it was appended additively for non-breaking contract evolution.
|
||||
// Every call site passes it by named argument (parentExecutionId:).
|
||||
Guid? parentExecutionId = null)
|
||||
{
|
||||
// InboundAPI-004: keep the timeout source and the request-abort source
|
||||
// separable. A single linked CTS makes a genuine client disconnect
|
||||
// indistinguishable from a method timeout, so a normal disconnect would be
|
||||
// logged and reported as "Script execution timed out". Use a dedicated
|
||||
// timeout CTS, linked with the request token, so the two can be told apart.
|
||||
using var timeoutCts = new CancellationTokenSource(timeout);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
// InboundAPI-016: bind the route helper to the method deadline so a
|
||||
// routed Route.To(...).Call(...) inherits the method-level timeout
|
||||
// without the script having to thread the context token by hand.
|
||||
//
|
||||
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
|
||||
// ExecutionId so a routed call carries it as ParentExecutionId — the
|
||||
// spawned site script execution points back at this inbound request.
|
||||
var context = new InboundScriptContext(
|
||||
parameters,
|
||||
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
|
||||
cts.Token);
|
||||
|
||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||
{
|
||||
// InboundAPI-009: a method already known to fail compilation must not
|
||||
// be recompiled on every request — short-circuit before Roslyn runs.
|
||||
if (_knownBadMethods.ContainsKey(method.Name))
|
||||
return new InboundScriptResult(false, null, "Script compilation failed for this method");
|
||||
|
||||
// Lazy compile on first request (handles methods created after startup).
|
||||
// Compile outside the cache so a failed compile is not stored, then add
|
||||
// atomically so concurrent first-callers share a single handler instance.
|
||||
var compiled = Compile(method);
|
||||
if (compiled == null)
|
||||
{
|
||||
// Cache the failure so the next request short-circuits above.
|
||||
// InboundAPI-024: routed through TryRecordBadMethod so the
|
||||
// cache is bounded under a flood of unique method names.
|
||||
TryRecordBadMethod(method.Name);
|
||||
return new InboundScriptResult(false, null, "Script compilation failed for this method");
|
||||
}
|
||||
handler = _scriptHandlers.GetOrAdd(method.Name, compiled);
|
||||
}
|
||||
|
||||
var result = await handler(context).WaitAsync(cts.Token);
|
||||
|
||||
var resultJson = result != null
|
||||
? JsonSerializer.Serialize(result)
|
||||
: null;
|
||||
|
||||
// InboundAPI-014: validate the script's return value against the
|
||||
// method's declared ReturnDefinition. A method whose script returns a
|
||||
// shape inconsistent with its definition must not silently emit a
|
||||
// malformed 200 — surface it as a script failure (500) and log.
|
||||
var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition);
|
||||
if (!returnValidation.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"API method {Method} return value rejected: {Error}",
|
||||
method.Name, returnValidation.ErrorMessage);
|
||||
return new InboundScriptResult(false, null, "Method return value did not match its return definition");
|
||||
}
|
||||
|
||||
return new InboundScriptResult(true, resultJson, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// InboundAPI-004: distinguish a genuine method timeout from a client
|
||||
// abort. Only the timeout CTS firing is a real timeout; if the caller's
|
||||
// request token fired, the client disconnected — do not pollute the
|
||||
// timeout log (reserved for genuine script-execution timeouts).
|
||||
if (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
|
||||
return new InboundScriptResult(false, null, "Script execution timed out");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name);
|
||||
return new InboundScriptResult(false, null, "Request cancelled by client");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
|
||||
// WP-5: Safe error message, no internal details
|
||||
return new InboundScriptResult(false, null, "Internal script error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to inbound API scripts.
|
||||
/// </summary>
|
||||
public class InboundScriptContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The script parameters.
|
||||
/// </summary>
|
||||
public ScriptParameters Parameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The route helper for cross-site routing.
|
||||
/// </summary>
|
||||
public RouteHelper Route { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The cancellation token for script execution.
|
||||
/// </summary>
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the InboundScriptContext.
|
||||
/// </summary>
|
||||
/// <param name="parameters">The input parameters for the script.</param>
|
||||
/// <param name="route">The route helper for cross-site routing.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public InboundScriptContext(
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
RouteHelper route,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Parameters = new ScriptParameters(parameters);
|
||||
Route = route;
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing an inbound API script.
|
||||
/// </summary>
|
||||
public record InboundScriptResult(
|
||||
bool Success,
|
||||
string? ResultJson,
|
||||
string? ErrorMessage);
|
||||
Reference in New Issue
Block a user