16bab58d85
Also updates the now-obsolete CompileAndRegister_NonCompilingUpdate test (InboundScriptExecutorTests.cs, not in the plan's Files list) to assert the deliberate DB-authoritative behavior: a broken save now 500s consistently on every node instead of the stale delegate silently serving. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
491 lines
24 KiB
C#
491 lines
24 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
|
|
|
/// <summary>
|
|
/// 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;
|
|
|
|
// A cached compiled handler, paired with the exact script text it was
|
|
// compiled from. The DB row is authoritative: on every request the cached
|
|
// Script is ordinal-compared against the freshly-fetched ApiMethod.Script; a
|
|
// mismatch means the cache is stale (a standby node compiled its own copy at
|
|
// startup, or the row was edited via direct SQL / bundle import) and the
|
|
// handler is recompiled in place. A null Script means "no revision info,
|
|
// serve as-is" — the RegisterHandler test seam, exempt from revision checks.
|
|
private sealed record CachedHandler(string? Script, Func<InboundScriptContext, Task<object?>> Handler);
|
|
|
|
// 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, CachedHandler> _scriptHandlers = new();
|
|
|
|
// A script that fails to compile (or violates the trust model)
|
|
// is recorded here — keyed by method name, valued by the exact script text
|
|
// that failed — 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. A record only
|
|
// short-circuits when the CURRENT script text is the one that failed: a changed
|
|
// script always gets a fresh compile. The entry is cleared whenever the method
|
|
// is (re)compiled successfully.
|
|
//
|
|
// 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, string> _knownBadMethods = new();
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
/// Records <paramref name="methodName"/> → <paramref name="script"/> in the
|
|
/// known-bad-methods cache so the CURRENT failing script text short-circuits the
|
|
/// next request. An existing record for the method is overwritten so a newer bad
|
|
/// script replaces an older one (the fast-fail always tracks the latest failure).
|
|
/// A brand-new record is only added while the cache has not reached
|
|
/// <see cref="KnownBadMethodsCap"/>; once full, new method names are dropped
|
|
/// (paying the cheap recompile next time rather than leaking memory under a
|
|
/// unique-name flood). Records are cleared on a successful (re)compile.
|
|
/// </summary>
|
|
private void TryRecordBadMethod(string methodName, string script)
|
|
{
|
|
// Overwrite an existing record (newer bad script wins) without counting
|
|
// against the cap; only a genuinely new method name is gated by the cap.
|
|
if (_knownBadMethods.ContainsKey(methodName))
|
|
{
|
|
_knownBadMethods[methodName] = script;
|
|
return;
|
|
}
|
|
if (_knownBadMethods.Count >= KnownBadMethodsCap)
|
|
return;
|
|
_knownBadMethods.TryAdd(methodName, script);
|
|
}
|
|
|
|
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. This is the script-less
|
|
/// test seam: the handler is stored with no associated script text, so it is
|
|
/// <b>exempt from the per-request revision check</b> and serves as-is regardless of
|
|
/// the fetched row's script. Production compiles always flow through
|
|
/// <see cref="CompileAndRegister(ApiMethod)"/>, which records the script text and is
|
|
/// revision-checked.
|
|
/// </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] = new CachedHandler(null, 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) => CompileAndRegister(method, out _);
|
|
|
|
/// <summary>
|
|
/// Compiles and registers a single API method script, additionally reporting the
|
|
/// compilation diagnostics. Returns <c>false</c> together with the human-readable
|
|
/// error list if the script is empty, fails Roslyn compilation, or violates the
|
|
/// script trust model. On failure this method does not overwrite any previously
|
|
/// registered handler, but the persisted DB row is authoritative: <see cref="ExecuteAsync"/>
|
|
/// revision-checks the cached handler against the freshly-fetched
|
|
/// <c>ApiMethod.Script</c> on every request, so once a broken script is saved the
|
|
/// method returns a compilation error consistently on every node rather than the
|
|
/// stale delegate silently serving. The management Create/Update path can therefore
|
|
/// surface a "saved but did not compile" warning that reflects real, consistent
|
|
/// behavior (see <c>HandleCreateApiMethod</c>/<c>HandleUpdateApiMethod</c>).
|
|
/// </summary>
|
|
/// <param name="method">The API method to compile and register.</param>
|
|
/// <param name="errors">On failure, the compilation/trust-model error messages; empty on success.</param>
|
|
/// <returns>True if successfully compiled and registered, false otherwise.</returns>
|
|
public bool CompileAndRegister(ApiMethod method, out IReadOnlyList<string> errors)
|
|
{
|
|
var (handler, compileErrors) = Compile(method);
|
|
if (handler == null)
|
|
{
|
|
// Record the failure so the lazy-compile path does not
|
|
// keep recompiling a broken script on every request. Routed
|
|
// through the capped TryRecordBadMethod helper so the cache
|
|
// cannot grow without bound under a flood of unique method names.
|
|
errors = compileErrors;
|
|
TryRecordBadMethod(method.Name, method.Script ?? string.Empty);
|
|
return false;
|
|
}
|
|
|
|
// The method definition was (re)compiled successfully — drop any stale
|
|
// failure record so a fixed script is no longer treated as bad.
|
|
errors = Array.Empty<string>();
|
|
_knownBadMethods.TryRemove(method.Name, out _);
|
|
return Register(method, handler);
|
|
}
|
|
|
|
private bool Register(ApiMethod method, Func<InboundScriptContext, Task<object?>> handler)
|
|
{
|
|
_scriptHandlers[method.Name] = new CachedHandler(method.Script, 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. Does not mutate the handler cache.
|
|
/// </summary>
|
|
private (Func<InboundScriptContext, Task<object?>>? Handler, IReadOnlyList<string> Errors) Compile(ApiMethod method)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(method.Script))
|
|
{
|
|
_logger.LogWarning("API method {Method} has no script code", method.Name);
|
|
return (null, new[] { "Method has no script code." });
|
|
}
|
|
|
|
// 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, violations.Select(v => "Trust model violation: " + v).ToList());
|
|
}
|
|
|
|
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, errors);
|
|
}
|
|
|
|
_logger.LogInformation("API method {Method} script compiled", method.Name);
|
|
return (async ctx =>
|
|
{
|
|
var state = await compiled.RunAsync(ctx, ctx.CancellationToken);
|
|
return state.ReturnValue;
|
|
}, Array.Empty<string>());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to compile API method {Method} script", method.Name);
|
|
return (null, new[] { "Compilation error: " + ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <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">
|
|
/// 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)
|
|
{
|
|
// 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);
|
|
|
|
// IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script
|
|
// (reads + writes). Resolve the gateway from a fresh DI scope
|
|
// (declared outside the try so it lives until after the script handler runs and is
|
|
// disposed here) so a scoped IDatabaseGateway is honoured. GetService (not
|
|
// GetRequiredService) so a method that never touches Database still runs even when
|
|
// no IDatabaseGateway is registered; the helper throws on first use if built
|
|
// without a gateway.
|
|
//
|
|
// A service provider that cannot produce a scope (e.g. a bare test double with
|
|
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
|
|
// a script not using Database still runs, while one that does fails on use.
|
|
IServiceScope? scope = null;
|
|
try
|
|
{
|
|
scope = _serviceProvider.CreateScope();
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
// No scope factory available (e.g. a non-scoping test-double provider).
|
|
// In production this should never happen; log so a genuine container
|
|
// misconfiguration is visible rather than silently disabling Database.
|
|
_logger.LogWarning(ex,
|
|
"Could not create a DI scope for method {Method}; Database will be unavailable to its script",
|
|
method.Name);
|
|
}
|
|
|
|
try
|
|
{
|
|
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
|
|
// Pass the method timeout so the helper derives a
|
|
// CommandTimeout backstop and forwards cts.Token to every async DB call —
|
|
// a slow query is then bounded by the method deadline.
|
|
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout);
|
|
|
|
// 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.
|
|
//
|
|
// Also pass the raw request-abort token separately so
|
|
// Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout
|
|
// (not the generic method deadline) while still being cancelled by a
|
|
// client disconnect — see RouteTarget.WaitForAttribute.
|
|
//
|
|
// Also bind the inbound request's ExecutionId (ParentExecutionId) 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)
|
|
.WithRequestAborted(cancellationToken)
|
|
.WithParentExecutionId(parentExecutionId),
|
|
dbHelper,
|
|
cts.Token);
|
|
|
|
_scriptHandlers.TryGetValue(method.Name, out var cached);
|
|
// Revision check: a cached handler compiled from different script text is stale
|
|
// (standby-after-failover, direct SQL edit, bundle import). Null Script = test seam, exempt.
|
|
if (cached is { Script: not null }
|
|
&& !string.Equals(cached.Script, method.Script, StringComparison.Ordinal))
|
|
{
|
|
_logger.LogInformation(
|
|
"API method {Method} script changed since it was cached; recompiling", method.Name);
|
|
cached = null;
|
|
}
|
|
|
|
if (cached == null)
|
|
{
|
|
// Fast-fail only when the CURRENT script text is the one already known bad.
|
|
if (_knownBadMethods.TryGetValue(method.Name, out var badScript)
|
|
&& string.Equals(badScript, method.Script, StringComparison.Ordinal))
|
|
return new InboundScriptResult(false, null, "Script compilation failed for this method");
|
|
|
|
// Lazy compile (handles methods created after startup, and stale/known-bad
|
|
// recompiles). Compile outside the cache so a failed compile is not stored,
|
|
// then add 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.
|
|
// Routed through TryRecordBadMethod so the
|
|
// cache is bounded under a flood of unique method names.
|
|
TryRecordBadMethod(method.Name, method.Script ?? string.Empty);
|
|
return new InboundScriptResult(false, null, "Script compilation failed for this method");
|
|
}
|
|
|
|
_knownBadMethods.TryRemove(method.Name, out _);
|
|
cached = new CachedHandler(method.Script, compiled);
|
|
_scriptHandlers[method.Name] = cached; // last-write-wins; concurrent first-callers race benignly
|
|
}
|
|
|
|
var result = await cached.Handler(context).WaitAsync(cts.Token);
|
|
|
|
var resultJson = result != null
|
|
? JsonSerializer.Serialize(result)
|
|
: null;
|
|
|
|
// Thread the JSON-Schema $ref resolution seam into return
|
|
// validation so a method whose ReturnDefinition uses a {"$ref":"lib:Name"}
|
|
// resolves the reference at RUNTIME (not just at deploy time). The
|
|
// shared-schema library is pre-loaded ONCE from the per-execution DI scope
|
|
// (the seam the validator consumes is synchronous), and ONLY when the
|
|
// definition actually uses a $ref — a $ref-free return definition skips the
|
|
// repository round-trip entirely. A dangling ref surfaces as a descriptive
|
|
// validation failure naming the missing reference, not an opaque parse error.
|
|
var sharedSchemaRepo =
|
|
scope?.ServiceProvider.GetService<ISharedSchemaRepository>();
|
|
var resolveRef = await SchemaRefResolver.BuildAsync(
|
|
sharedSchemaRepo, [method.ReturnDefinition], cts.Token);
|
|
|
|
// 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, resolveRef);
|
|
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)
|
|
{
|
|
// 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);
|
|
// Safe error message, no internal details
|
|
return new InboundScriptResult(false, null, "Internal script error");
|
|
}
|
|
finally
|
|
{
|
|
// Dispose the per-execution DI scope (if one was created) only after the
|
|
// script handler has finished running and its result been serialized.
|
|
scope?.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// Scoped, parameterized database access for the script (named connections; bound
|
|
/// parameters; reads and writes — see <see cref="InboundDatabaseHelper"/>).
|
|
/// </summary>
|
|
public InboundDatabaseHelper Database { 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="database">The scoped, parameterized database helper for the script.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public InboundScriptContext(
|
|
IReadOnlyDictionary<string, object?> parameters,
|
|
RouteHelper route,
|
|
InboundDatabaseHelper database,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Parameters = new ScriptParameters(parameters);
|
|
Route = route;
|
|
Database = database;
|
|
CancellationToken = cancellationToken;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of executing an inbound API script.
|
|
/// </summary>
|
|
public record InboundScriptResult(
|
|
bool Success,
|
|
string? ResultJson,
|
|
string? ErrorMessage);
|