feat(inbound-api): surface method compile errors to operators — api-method validate/recompile + compile state on get (ScadaBridge#75)
The data plane answers a broken inbound method with the generic "Script compilation failed for this method"; the Roslyn diagnostic reached only the central log, and the known-bad cache could not be observed or reset per method. - InboundScriptExecutor keeps the diagnostics with each known-bad record and exposes GetCompileState(name, currentScript): IsCompiled / IsKnownBad / LastCompileError, judged against the current script text (a record for older text is reported as not compiled yet). - Management: ValidateApiMethodCommand (compile the stored script, return ApiMethodCompileResult, register nothing, cache untouched) and RecompileApiMethodCommand (CompileAndRegister without re-sending the entity, drops the node's ApiMethodCache row, audited "Recompile"). Both Designer, like UpdateApiMethod; dispatched through the shared ApiMethodCompileOperations. GetApiMethod keeps the entity fields top-level and adds isCompiled, isKnownBad, lastCompileError and compileStateNode. Compile state is per central node and every result names the node. - CLI: api-method validate --id, api-method recompile --id. - Central UI: the API-method edit page shows the saved script's compile state and last error, with Validate and Recompile actions (same operations, same Designer gate, fresh DI scope). - RequiredRoleMatrixTests entries; ManagementSurfaceParityTests covers the new CLI leaves; ApiMethodCompileDiagnosticsTests + an executor CompileState test. - Docs: CLI README, Component-CLI/ManagementService/InboundAPI, components/*. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -23,6 +23,8 @@ public static class ApiMethodCommands
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildValidate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildRecompile(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -52,6 +54,40 @@ public static class ApiMethodCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildValidate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||
var cmd = new Command("validate")
|
||||
{
|
||||
Description = "Compile an API method's stored script on the central node and show the compile errors (nothing is registered)"
|
||||
};
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ValidateApiMethodCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildRecompile(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "API method ID", Required = true };
|
||||
var cmd = new Command("recompile")
|
||||
{
|
||||
Description = "Recompile and re-register an API method's stored script on the central node, clearing a stale compile failure"
|
||||
};
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new RecompileApiMethodCommand(id));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
|
||||
|
||||
@@ -2342,7 +2342,13 @@ scadabridge --url <url> api-method list
|
||||
|
||||
#### `api-method get`
|
||||
|
||||
Get a single inbound API method by ID.
|
||||
Get a single inbound API method by ID. Besides the method's fields the reply carries the
|
||||
answering central node's compile state for the stored script: `isCompiled`, `isKnownBad`
|
||||
(the script failed to compile and requests fast-fail with the generic
|
||||
`Script compilation failed for this method`), `lastCompileError` (the real compiler /
|
||||
trust-model diagnostics when `isKnownBad`) and `compileStateNode`. Each central node
|
||||
compiles independently, so the state is per node; the fields are null on a host with no
|
||||
inbound API executor.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> api-method get --id <int>
|
||||
@@ -2352,6 +2358,37 @@ scadabridge --url <url> api-method get --id <int>
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | API method ID |
|
||||
|
||||
#### `api-method validate`
|
||||
|
||||
Compile the method's **stored** script on the central node and return
|
||||
`{ apiMethodId, name, success, errors, node }` — the same compile the data plane runs, with
|
||||
the diagnostics the data plane deliberately hides from API-key callers. Nothing is
|
||||
registered and the known-bad cache is untouched. Requires `Designer`.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> api-method validate --id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | API method ID |
|
||||
|
||||
#### `api-method recompile`
|
||||
|
||||
Re-run `CompileAndRegister` on the method's stored script on the central node, without
|
||||
re-sending the entity: a success replaces the cached handler and clears a stale compile
|
||||
failure (for example one caused by something outside the script that has since been
|
||||
fixed); a failure records the new diagnostics. Returns the same shape as `validate` and is
|
||||
audited as `Recompile`. Affects the answering node only. Requires `Designer`.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> api-method recompile --id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | API method ID |
|
||||
|
||||
#### `api-method create`
|
||||
|
||||
Create a new inbound API method.
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.ManagementService
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@@ -123,6 +126,65 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@if (_existing != null)
|
||||
{
|
||||
@* ScadaBridge#75: the data plane answers a broken method with a generic
|
||||
"Script compilation failed for this method"; operators read the real
|
||||
diagnostics here (same operations as CLI api-method get/validate/recompile). *@
|
||||
<fieldset class="mb-4" data-test="api-method-compile">
|
||||
<legend class="h6">Compilation (saved script)</legend>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
|
||||
@if (_compileStatus is null)
|
||||
{
|
||||
<span class="badge bg-secondary" data-test="api-method-compile-status">Unavailable on this node</span>
|
||||
}
|
||||
else if (_compileStatus.IsKnownBad)
|
||||
{
|
||||
<span class="badge bg-danger" data-test="api-method-compile-status">Compile failed</span>
|
||||
}
|
||||
else if (_compileStatus.IsCompiled)
|
||||
{
|
||||
<span class="badge bg-success" data-test="api-method-compile-status">Compiled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary" data-test="api-method-compile-status">Not compiled yet</span>
|
||||
}
|
||||
@if (_compileStatus?.Node is { } node)
|
||||
{
|
||||
<span class="text-muted small">on node @node</span>
|
||||
}
|
||||
<button class="btn btn-outline-secondary btn-sm" data-test="api-method-validate"
|
||||
@onclick="ValidateSavedScriptAsync" disabled="@_compiling">Validate</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" data-test="api-method-recompile"
|
||||
@onclick="RecompileSavedScriptAsync" disabled="@_compiling">Recompile</button>
|
||||
</div>
|
||||
@if (_compileStatus?.LastCompileError is { } lastError)
|
||||
{
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-2 font-monospace text-danger" style="white-space: pre-wrap;">@lastError</pre>
|
||||
}
|
||||
@if (_compileResult != null)
|
||||
{
|
||||
<div class="small @(_compileResult.Success ? "text-success" : "text-danger")" data-test="api-method-compile-result">
|
||||
@_compileAction: @(_compileResult.Success ? "the saved script compiles." : "the saved script does not compile.")
|
||||
</div>
|
||||
@if (_compileResult.Errors.Count > 0)
|
||||
{
|
||||
<ul class="small text-danger mt-1 mb-0">
|
||||
@foreach (var error in _compileResult.Errors)
|
||||
{
|
||||
<li>@error</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
<div class="form-text">
|
||||
Checks the script as saved, not unsaved edits. Recompile re-registers it on this
|
||||
central node, clearing a stale compile failure.
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
@@ -245,6 +307,10 @@
|
||||
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _showTestRun;
|
||||
private ApiMethodCompileStatus? _compileStatus;
|
||||
private ApiMethodCompileResult? _compileResult;
|
||||
private string? _compileAction;
|
||||
private bool _compiling;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
@@ -277,6 +343,7 @@
|
||||
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
|
||||
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
|
||||
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
|
||||
_compileStatus = ApiMethodCompileOperations.GetStatus(MutationServices, _existing);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -284,6 +351,35 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private Task ValidateSavedScriptAsync() =>
|
||||
RunCompileActionAsync("Validate", (sp, _) => ApiMethodCompileOperations.ValidateAsync(sp, _existing!.Id));
|
||||
|
||||
private Task RecompileSavedScriptAsync() =>
|
||||
RunCompileActionAsync("Recompile", (sp, user) => ApiMethodCompileOperations.RecompileAsync(sp, _existing!.Id, user));
|
||||
|
||||
/// <summary>
|
||||
/// Runs a compile action under the same Designer gate as the management commands, in a
|
||||
/// fresh DI scope (the recompile writes an audit row), then refreshes the status badge.
|
||||
/// </summary>
|
||||
private async Task RunCompileActionAsync(
|
||||
string action, Func<IServiceProvider, string, Task<ApiMethodCompileResult>> operation)
|
||||
{
|
||||
if (_existing is null) return;
|
||||
_formError = null;
|
||||
_compiling = true;
|
||||
try
|
||||
{
|
||||
var user = await MutationAuth.RequireRoleAsync(Roles.Designer);
|
||||
await using var scope = MutationServices.GetRequiredService<IServiceScopeFactory>().CreateAsyncScope();
|
||||
_compileResult = await operation(scope.ServiceProvider, user);
|
||||
_compileAction = action;
|
||||
var saved = await scope.ServiceProvider.GetRequiredService<IInboundApiRepository>().GetApiMethodByIdAsync(_existing.Id);
|
||||
_compileStatus = saved is null ? null : ApiMethodCompileOperations.GetStatus(scope.ServiceProvider, saved);
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
finally { _compiling = false; }
|
||||
}
|
||||
|
||||
private void ToggleKey(string keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
|
||||
@@ -7,3 +7,18 @@ public record CreateApiMethodCommand(string Name, string Script, int TimeoutSeco
|
||||
/// <param name="TraceAllCalls">Flow Debugger (design 2026-09-05 §5.2): <c>null</c> leaves the stored value unchanged so an older CLI never resets it.</param>
|
||||
public record UpdateApiMethodCommand(int ApiMethodId, string Script, int TimeoutSeconds, string? ParameterDefinitions, string? ReturnDefinition, bool? TraceAllCalls = null);
|
||||
public record DeleteApiMethodCommand(int ApiMethodId);
|
||||
/// <summary>
|
||||
/// Compiles the method's stored script on the receiving central node and returns the
|
||||
/// diagnostics, without registering the handler or touching the known-bad cache
|
||||
/// (ScadaBridge#75). Operators see the real compile error here; the data plane keeps
|
||||
/// returning its generic message.
|
||||
/// </summary>
|
||||
/// <param name="ApiMethodId">The API method to validate.</param>
|
||||
public record ValidateApiMethodCommand(int ApiMethodId);
|
||||
/// <summary>
|
||||
/// Re-runs <c>CompileAndRegister</c> for the method's stored script on the receiving central
|
||||
/// node, replacing its cached handler or known-bad record, without re-sending the entity
|
||||
/// (ScadaBridge#75). Returns the diagnostics.
|
||||
/// </summary>
|
||||
/// <param name="ApiMethodId">The API method to recompile.</param>
|
||||
public record RecompileApiMethodCommand(int ApiMethodId);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>ValidateApiMethodCommand</c> / <c>RecompileApiMethodCommand</c>
|
||||
/// (ScadaBridge#75): the outcome of compiling an inbound API method's stored script on one
|
||||
/// central node, with the compiler / trust-model diagnostics an operator needs.
|
||||
/// </summary>
|
||||
/// <param name="ApiMethodId">The API method id.</param>
|
||||
/// <param name="Name">The API method name.</param>
|
||||
/// <param name="Success">True when the script compiled.</param>
|
||||
/// <param name="Errors">The compile / trust-model errors; empty on success.</param>
|
||||
/// <param name="Node">The central node that compiled it (compile state is per node), when known.</param>
|
||||
public sealed record ApiMethodCompileResult(
|
||||
int ApiMethodId, string Name, bool Success, IReadOnlyList<string> Errors, string? Node);
|
||||
|
||||
/// <summary>
|
||||
/// One central node's runtime compile state for an inbound API method (ScadaBridge#75),
|
||||
/// judged against the method's current stored script.
|
||||
/// </summary>
|
||||
/// <param name="IsCompiled">A handler compiled from the current script is cached on this node.</param>
|
||||
/// <param name="IsKnownBad">The current script failed to compile on this node; requests fast-fail.</param>
|
||||
/// <param name="LastCompileError">The diagnostics of that failure, when <paramref name="IsKnownBad"/>.</param>
|
||||
/// <param name="Node">The central node the state was read from, when known.</param>
|
||||
public sealed record ApiMethodCompileStatus(
|
||||
bool IsCompiled, bool IsKnownBad, string? LastCompileError, string? Node);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// This node's compile state for one inbound API method, as reported by
|
||||
/// <see cref="InboundScriptExecutor.GetCompileState"/> (ScadaBridge#75).
|
||||
/// </summary>
|
||||
/// <param name="IsCompiled">A handler compiled from the current script text is cached.</param>
|
||||
/// <param name="IsKnownBad">The current script text failed to compile; requests fast-fail without recompiling.</param>
|
||||
/// <param name="LastCompileError">The joined diagnostics of that failure, when <paramref name="IsKnownBad"/>.</param>
|
||||
public sealed record ApiMethodCompileState(bool IsCompiled, bool IsKnownBad, string? LastCompileError);
|
||||
@@ -57,7 +57,12 @@ public class InboundScriptExecutor
|
||||
// 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();
|
||||
private readonly ConcurrentDictionary<string, KnownBadMethod> _knownBadMethods = new();
|
||||
|
||||
// A known-bad record: the exact script text that failed and the diagnostics it failed
|
||||
// with, so an operator can read them through the management surface (ScadaBridge#75)
|
||||
// instead of the central log.
|
||||
private sealed record KnownBadMethod(string Script, IReadOnlyList<string> Errors);
|
||||
|
||||
/// <summary>Diagnostic helper — whether a compiled handler is cached for the method.</summary>
|
||||
internal bool HasHandler(string methodName) => _scriptHandlers.ContainsKey(methodName);
|
||||
@@ -69,6 +74,31 @@ public class InboundScriptExecutor
|
||||
/// </summary>
|
||||
internal int KnownBadMethodCount => _knownBadMethods.Count;
|
||||
|
||||
/// <summary>
|
||||
/// This node's compile state for <paramref name="methodName"/>, judged against the
|
||||
/// method's CURRENT script text (ScadaBridge#75). A handler or failure record left over
|
||||
/// from different script text describes an older revision, so it is reported as not yet
|
||||
/// compiled — the next request (or a recompile) compiles the current text. The state is
|
||||
/// per central node: each node compiles and caches independently.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The method name.</param>
|
||||
/// <param name="currentScript">The method's current (persisted) script text.</param>
|
||||
/// <returns>The compile state of the method on this node.</returns>
|
||||
public ApiMethodCompileState GetCompileState(string methodName, string? currentScript)
|
||||
{
|
||||
var script = currentScript ?? string.Empty;
|
||||
if (_knownBadMethods.TryGetValue(methodName, out var bad)
|
||||
&& string.Equals(bad.Script, script, StringComparison.Ordinal))
|
||||
{
|
||||
return new ApiMethodCompileState(IsCompiled: false, IsKnownBad: true, string.Join("; ", bad.Errors));
|
||||
}
|
||||
|
||||
// A null Script is the RegisterHandler test seam, which serves regardless of text.
|
||||
var compiled = _scriptHandlers.TryGetValue(methodName, out var cached)
|
||||
&& (cached.Script is null || string.Equals(cached.Script, script, StringComparison.Ordinal));
|
||||
return new ApiMethodCompileState(IsCompiled: compiled, IsKnownBad: false, LastCompileError: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records <paramref name="methodName"/> → <paramref name="script"/> in the
|
||||
/// known-bad-methods cache so the CURRENT failing script text short-circuits the
|
||||
@@ -79,18 +109,19 @@ public class InboundScriptExecutor
|
||||
/// (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)
|
||||
private void TryRecordBadMethod(string methodName, string script, IReadOnlyList<string> errors)
|
||||
{
|
||||
var record = new KnownBadMethod(script, errors);
|
||||
// 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;
|
||||
_knownBadMethods[methodName] = record;
|
||||
return;
|
||||
}
|
||||
if (_knownBadMethods.Count >= KnownBadMethodsCap)
|
||||
return;
|
||||
_knownBadMethods.TryAdd(methodName, script);
|
||||
_knownBadMethods.TryAdd(methodName, record);
|
||||
}
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
@@ -227,7 +258,7 @@ public class InboundScriptExecutor
|
||||
// through the capped TryRecordBadMethod helper so the cache
|
||||
// cannot grow without bound under a flood of unique method names.
|
||||
errors = compileErrors;
|
||||
publish = () => TryRecordBadMethod(name, script ?? string.Empty);
|
||||
publish = () => TryRecordBadMethod(name, script ?? string.Empty, compileErrors);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -562,20 +593,20 @@ public class InboundScriptExecutor
|
||||
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))
|
||||
if (_knownBadMethods.TryGetValue(method.Name, out var knownBad)
|
||||
&& string.Equals(knownBad.Script, method.Script, StringComparison.Ordinal))
|
||||
return new InboundScriptResult(false, null, "Script compilation failed for this method", "SCRIPT_COMPILE_FAILED");
|
||||
|
||||
// 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, _) = CompileSingleFlight(method);
|
||||
var (compiled, compileErrors) = CompileSingleFlight(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);
|
||||
TryRecordBadMethod(method.Name, method.Script ?? string.Empty, compileErrors);
|
||||
return new InboundScriptResult(false, null, "Script compilation failed for this method", "SCRIPT_COMPILE_FAILED");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
/// <summary>
|
||||
/// Operator access to inbound API method compilation (ScadaBridge#75): the real compile
|
||||
/// diagnostics, this node's compile state, and a recompile that does not need the entity
|
||||
/// re-sent. Shared by the <see cref="ManagementActor"/> handlers (CLI / Management API) and
|
||||
/// the Central UI, which call it under the same role gate, so every surface reports the same
|
||||
/// thing. Compile state is per central node — each node compiles and caches independently —
|
||||
/// so every result names the node it came from.
|
||||
/// </summary>
|
||||
public static class ApiMethodCompileOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiles the method's stored script and returns the diagnostics. Nothing is registered
|
||||
/// and the known-bad cache is untouched, so a validate never changes what requests see.
|
||||
/// </summary>
|
||||
/// <param name="sp">The scoped service provider.</param>
|
||||
/// <param name="apiMethodId">The API method id.</param>
|
||||
/// <returns>The compile outcome and its diagnostics.</returns>
|
||||
public static async Task<ApiMethodCompileResult> ValidateAsync(IServiceProvider sp, int apiMethodId)
|
||||
{
|
||||
var method = await LoadAsync(sp, apiMethodId);
|
||||
var valid = RequireExecutor(sp).ValidateCompilation(method, out var errors);
|
||||
return new ApiMethodCompileResult(method.Id, method.Name, valid, errors, NodeName(sp));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-runs <c>CompileAndRegister</c> on the method's stored script: a success replaces the
|
||||
/// cached handler and clears the known-bad record, a failure records the new diagnostics
|
||||
/// (a previously working handler is never replaced by a failed compile, but the stored row
|
||||
/// stays authoritative, see <see cref="InboundScriptExecutor.CompileAndRegister(ApiMethod, out IReadOnlyList{string})"/>).
|
||||
/// The node's cached row is dropped too, so the next request reads the stored row.
|
||||
/// Audited as <c>Recompile</c>.
|
||||
/// </summary>
|
||||
/// <param name="sp">The scoped service provider.</param>
|
||||
/// <param name="apiMethodId">The API method id.</param>
|
||||
/// <param name="user">The acting user, for the audit row.</param>
|
||||
/// <returns>The compile outcome and its diagnostics.</returns>
|
||||
public static async Task<ApiMethodCompileResult> RecompileAsync(IServiceProvider sp, int apiMethodId, string user)
|
||||
{
|
||||
var method = await LoadAsync(sp, apiMethodId);
|
||||
var compiled = RequireExecutor(sp).CompileAndRegister(method, out var errors);
|
||||
sp.GetService<ApiMethodCache>()?.Invalidate(method.Name);
|
||||
|
||||
var result = new ApiMethodCompileResult(method.Id, method.Name, compiled, errors, NodeName(sp));
|
||||
await sp.GetRequiredService<IAuditService>().LogAsync(
|
||||
user, "Recompile", "ApiMethod", method.Id.ToString(), method.Name,
|
||||
new { method.Id, method.Name, Compiled = compiled, ErrorCount = errors.Count, result.Node });
|
||||
await sp.GetRequiredService<IInboundApiRepository>().SaveChangesAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This node's compile state for <paramref name="method"/>, or <c>null</c> on a host that
|
||||
/// runs no inbound API executor.
|
||||
/// </summary>
|
||||
/// <param name="sp">The service provider.</param>
|
||||
/// <param name="method">The API method, with its current stored script.</param>
|
||||
/// <returns>The compile state, or <c>null</c>.</returns>
|
||||
public static ApiMethodCompileStatus? GetStatus(IServiceProvider sp, ApiMethod method)
|
||||
{
|
||||
var executor = sp.GetService<InboundScriptExecutor>();
|
||||
if (executor is null)
|
||||
return null;
|
||||
var state = executor.GetCompileState(method.Name, method.Script);
|
||||
return new ApiMethodCompileStatus(state.IsCompiled, state.IsKnownBad, state.LastCompileError, NodeName(sp));
|
||||
}
|
||||
|
||||
private static async Task<ApiMethod> LoadAsync(IServiceProvider sp, int apiMethodId) =>
|
||||
await sp.GetRequiredService<IInboundApiRepository>().GetApiMethodByIdAsync(apiMethodId)
|
||||
?? throw new ManagementCommandException($"ApiMethod with ID {apiMethodId} not found.");
|
||||
|
||||
private static InboundScriptExecutor RequireExecutor(IServiceProvider sp) =>
|
||||
sp.GetService<InboundScriptExecutor>()
|
||||
?? throw new ManagementCommandException("The inbound API script executor is not available on this node.");
|
||||
|
||||
private static string? NodeName(IServiceProvider sp) => sp.GetService<INodeIdentityProvider>()?.NodeName;
|
||||
}
|
||||
@@ -328,6 +328,9 @@ public class ManagementActor : ReceiveActor
|
||||
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
|
||||
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
|
||||
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
|
||||
// ScadaBridge#75: operator compile diagnostics + targeted recompile, the same
|
||||
// authority as UpdateApiMethod (which recompiles as a side effect).
|
||||
or ValidateApiMethodCommand or RecompileApiMethodCommand
|
||||
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
|
||||
or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
|
||||
or MoveTemplateToFolderCommand
|
||||
@@ -526,6 +529,8 @@ public class ManagementActor : ReceiveActor
|
||||
CreateApiMethodCommand cmd => await HandleCreateApiMethod(sp, cmd, user.Username),
|
||||
UpdateApiMethodCommand cmd => await HandleUpdateApiMethod(sp, cmd, user.Username),
|
||||
DeleteApiMethodCommand cmd => await HandleDeleteApiMethod(sp, cmd, user.Username),
|
||||
ValidateApiMethodCommand cmd => await ApiMethodCompileOperations.ValidateAsync(sp, cmd.ApiMethodId),
|
||||
RecompileApiMethodCommand cmd => await ApiMethodCompileOperations.RecompileAsync(sp, cmd.ApiMethodId, user.Username),
|
||||
|
||||
// Security
|
||||
ListRoleMappingsCommand => await HandleListRoleMappings(sp),
|
||||
@@ -3802,7 +3807,26 @@ public class ManagementActor : ReceiveActor
|
||||
private static async Task<object?> HandleGetApiMethod(IServiceProvider sp, GetApiMethodCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
||||
return await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
|
||||
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
|
||||
if (method is null)
|
||||
return null;
|
||||
// ScadaBridge#75: the entity fields stay top-level (additive), plus this node's
|
||||
// compile state so an operator can see why a method fails without firing it.
|
||||
var status = ApiMethodCompileOperations.GetStatus(sp, method);
|
||||
return new
|
||||
{
|
||||
method.Id,
|
||||
method.Name,
|
||||
method.Script,
|
||||
method.ParameterDefinitions,
|
||||
method.ReturnDefinition,
|
||||
method.TimeoutSeconds,
|
||||
method.TraceAllCalls,
|
||||
status?.IsCompiled,
|
||||
status?.IsKnownBad,
|
||||
status?.LastCompileError,
|
||||
CompileStateNode = status?.Node,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleCreateApiMethod(IServiceProvider sp, CreateApiMethodCommand cmd, string user)
|
||||
|
||||
Reference in New Issue
Block a user