Phase 8: Production readiness — failover tests, security hardening, sandboxing, deployment docs

- WP-1-3: Central/site failover + dual-node recovery tests (17 tests)
- WP-4: Performance testing framework for target scale (7 tests)
- WP-5: Security hardening (LDAPS, JWT key length, no secrets in logs) (11 tests)
- WP-6: Script sandboxing adversarial tests (28 tests, all forbidden APIs)
- WP-7: Recovery drill test scaffolds (5 tests)
- WP-8: Observability validation (structured logs, correlation IDs, metrics) (6 tests)
- WP-9: Message contract compatibility (forward/backward compat) (18 tests)
- WP-10: Deployment packaging (installation guide, production checklist, topology)
- WP-11: Operational runbooks (failover, troubleshooting, maintenance)
92 new tests, all passing. Zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 22:12:31 -04:00
parent 3b2320bd35
commit b659978764
68 changed files with 6253 additions and 44 deletions

View File

@@ -0,0 +1,162 @@
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Communication;
namespace ScadaLink.InboundAPI;
/// <summary>
/// WP-4: Route.To() helper for cross-site calls from inbound API scripts.
/// Resolves instance to site, routes via CommunicationService, blocks until response or timeout.
/// Site unreachable returns error (no store-and-forward).
/// </summary>
public class RouteHelper
{
private readonly IInstanceLocator _instanceLocator;
private readonly CommunicationService _communicationService;
public RouteHelper(
IInstanceLocator instanceLocator,
CommunicationService communicationService)
{
_instanceLocator = instanceLocator;
_communicationService = communicationService;
}
/// <summary>
/// Creates a route target for the specified instance.
/// </summary>
public RouteTarget To(string instanceCode)
{
return new RouteTarget(instanceCode, _instanceLocator, _communicationService);
}
}
/// <summary>
/// WP-4: Represents a route target (an instance) for cross-site calls.
/// </summary>
public class RouteTarget
{
private readonly string _instanceCode;
private readonly IInstanceLocator _instanceLocator;
private readonly CommunicationService _communicationService;
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
CommunicationService communicationService)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_communicationService = communicationService;
}
/// <summary>
/// Calls a script on the remote instance. Synchronous from API caller's perspective.
/// </summary>
public async Task<object?> Call(
string scriptName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
var correlationId = Guid.NewGuid().ToString();
var request = new RouteToCallRequest(
correlationId, _instanceCode, scriptName, parameters, DateTimeOffset.UtcNow);
var response = await _communicationService.RouteToCallAsync(
siteId, request, cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote script call failed");
}
return response.ReturnValue;
}
/// <summary>
/// Gets a single attribute value from the remote instance.
/// </summary>
public async Task<object?> GetAttribute(
string attributeName,
CancellationToken cancellationToken = default)
{
var result = await GetAttributes(new[] { attributeName }, cancellationToken);
return result.TryGetValue(attributeName, out var value) ? value : null;
}
/// <summary>
/// Gets multiple attribute values from the remote instance (batch read).
/// </summary>
public async Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
var correlationId = Guid.NewGuid().ToString();
var request = new RouteToGetAttributesRequest(
correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow);
var response = await _communicationService.RouteToGetAttributesAsync(
siteId, request, cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote attribute read failed");
}
return response.Values;
}
/// <summary>
/// Sets a single attribute value on the remote instance.
/// </summary>
public async Task SetAttribute(
string attributeName,
string value,
CancellationToken cancellationToken = default)
{
await SetAttributes(
new Dictionary<string, string> { { attributeName, value } },
cancellationToken);
}
/// <summary>
/// Sets multiple attribute values on the remote instance (batch write).
/// </summary>
public async Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default)
{
var siteId = await ResolveSiteAsync(cancellationToken);
var correlationId = Guid.NewGuid().ToString();
var request = new RouteToSetAttributesRequest(
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow);
var response = await _communicationService.RouteToSetAttributesAsync(
siteId, request, cancellationToken);
if (!response.Success)
{
throw new InvalidOperationException(
response.ErrorMessage ?? "Remote attribute write failed");
}
}
private async Task<string> ResolveSiteAsync(CancellationToken cancellationToken)
{
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);
if (siteId == null)
{
throw new InvalidOperationException(
$"Instance '{_instanceCode}' not found or has no assigned site");
}
return siteId;
}
}