feat: wire SQLite replication between site nodes and fix ConfigurationDatabase tests
Add SiteReplicationActor (runs on every site node) to replicate deployed configs and store-and-forward buffer operations to the standby peer via cluster member discovery and fire-and-forget Tell. Wire ReplicationService handler and pass replication actor to DeploymentManagerActor singleton. Fix 5 pre-existing ConfigurationDatabase test failures: RowVersion NOT NULL on SQLite, stale migration name assertion, and seed data count mismatch.
This commit is contained in:
@@ -15,6 +15,7 @@ public static class ExternalSystemCommands
|
||||
command.Add(BuildCreate(contactPointsOption, formatOption));
|
||||
command.Add(BuildUpdate(contactPointsOption, formatOption));
|
||||
command.Add(BuildDelete(contactPointsOption, formatOption));
|
||||
command.Add(BuildMethodGroup(contactPointsOption, formatOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -110,4 +111,121 @@ public static class ExternalSystemCommands
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── Method subcommands ──
|
||||
|
||||
private static Command BuildMethodGroup(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
{
|
||||
var group = new Command("method") { Description = "Manage external system methods" };
|
||||
group.Add(BuildMethodList(contactPointsOption, formatOption));
|
||||
group.Add(BuildMethodGet(contactPointsOption, formatOption));
|
||||
group.Add(BuildMethodCreate(contactPointsOption, formatOption));
|
||||
group.Add(BuildMethodUpdate(contactPointsOption, formatOption));
|
||||
group.Add(BuildMethodDelete(contactPointsOption, formatOption));
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildMethodList(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
{
|
||||
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
|
||||
var cmd = new Command("list") { Description = "List methods for an external system" };
|
||||
cmd.Add(sysIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, contactPointsOption, formatOption,
|
||||
new ListExternalSystemMethodsCommand(result.GetValue(sysIdOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodGet(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var cmd = new Command("get") { Description = "Get an external system method by ID" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, contactPointsOption, formatOption,
|
||||
new GetExternalSystemMethodCommand(result.GetValue(idOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodCreate(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
{
|
||||
var sysIdOption = new Option<int>("--external-system-id") { Description = "External system ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Method name", Required = true };
|
||||
var httpMethodOption = new Option<string>("--http-method") { Description = "HTTP method (GET, POST, PUT, DELETE)", Required = true };
|
||||
var pathOption = new Option<string>("--path") { Description = "URL path (e.g. /api/Add)", Required = true };
|
||||
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
|
||||
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create an external system method" };
|
||||
cmd.Add(sysIdOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(httpMethodOption);
|
||||
cmd.Add(pathOption);
|
||||
cmd.Add(paramsOption);
|
||||
cmd.Add(returnOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, contactPointsOption, formatOption,
|
||||
new CreateExternalSystemMethodCommand(
|
||||
result.GetValue(sysIdOption),
|
||||
result.GetValue(nameOption)!,
|
||||
result.GetValue(httpMethodOption)!,
|
||||
result.GetValue(pathOption)!,
|
||||
result.GetValue(paramsOption),
|
||||
result.GetValue(returnOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodUpdate(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var nameOption = new Option<string?>("--name") { Description = "Method name" };
|
||||
var httpMethodOption = new Option<string?>("--http-method") { Description = "HTTP method" };
|
||||
var pathOption = new Option<string?>("--path") { Description = "URL path" };
|
||||
var paramsOption = new Option<string?>("--params") { Description = "Parameter definitions JSON" };
|
||||
var returnOption = new Option<string?>("--return") { Description = "Return definition JSON" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an external system method" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(httpMethodOption);
|
||||
cmd.Add(pathOption);
|
||||
cmd.Add(paramsOption);
|
||||
cmd.Add(returnOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, contactPointsOption, formatOption,
|
||||
new UpdateExternalSystemMethodCommand(
|
||||
result.GetValue(idOption),
|
||||
result.GetValue(nameOption),
|
||||
result.GetValue(httpMethodOption),
|
||||
result.GetValue(pathOption),
|
||||
result.GetValue(paramsOption),
|
||||
result.GetValue(returnOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildMethodDelete(Option<string> contactPointsOption, Option<string> formatOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Method ID", Required = true };
|
||||
var cmd = new Command("delete") { Description = "Delete an external system method" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, contactPointsOption, formatOption,
|
||||
new DeleteExternalSystemMethodCommand(result.GetValue(idOption)));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +300,9 @@ public static class TemplateCommands
|
||||
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
lockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var paramsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var returnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
|
||||
|
||||
var addCmd = new Command("add") { Description = "Add a script to a template" };
|
||||
addCmd.Add(templateIdOption);
|
||||
addCmd.Add(nameOption);
|
||||
@@ -307,6 +310,8 @@ public static class TemplateCommands
|
||||
addCmd.Add(triggerTypeOption);
|
||||
addCmd.Add(triggerConfigOption);
|
||||
addCmd.Add(lockedOption);
|
||||
addCmd.Add(paramsOption);
|
||||
addCmd.Add(returnOption);
|
||||
addCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
@@ -317,7 +322,9 @@ public static class TemplateCommands
|
||||
result.GetValue(codeOption)!,
|
||||
result.GetValue(triggerTypeOption)!,
|
||||
result.GetValue(triggerConfigOption),
|
||||
result.GetValue(lockedOption)));
|
||||
result.GetValue(lockedOption),
|
||||
result.GetValue(paramsOption),
|
||||
result.GetValue(returnOption)));
|
||||
});
|
||||
group.Add(addCmd);
|
||||
|
||||
@@ -329,6 +336,9 @@ public static class TemplateCommands
|
||||
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
|
||||
updateLockedOption.DefaultValueFactory = _ => false;
|
||||
|
||||
var updateParamsOption = new Option<string?>("--parameters") { Description = "Parameter definitions JSON" };
|
||||
var updateReturnOption = new Option<string?>("--return-def") { Description = "Return definition JSON" };
|
||||
|
||||
var updateCmd = new Command("update") { Description = "Update a template script" };
|
||||
updateCmd.Add(updateIdOption);
|
||||
updateCmd.Add(updateNameOption);
|
||||
@@ -336,6 +346,8 @@ public static class TemplateCommands
|
||||
updateCmd.Add(updateTriggerTypeOption);
|
||||
updateCmd.Add(updateTriggerConfigOption);
|
||||
updateCmd.Add(updateLockedOption);
|
||||
updateCmd.Add(updateParamsOption);
|
||||
updateCmd.Add(updateReturnOption);
|
||||
updateCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
@@ -346,7 +358,9 @@ public static class TemplateCommands
|
||||
result.GetValue(updateCodeOption)!,
|
||||
result.GetValue(updateTriggerTypeOption)!,
|
||||
result.GetValue(updateTriggerConfigOption),
|
||||
result.GetValue(updateLockedOption)));
|
||||
result.GetValue(updateLockedOption),
|
||||
result.GetValue(updateParamsOption),
|
||||
result.GetValue(updateReturnOption)));
|
||||
});
|
||||
group.Add(updateCmd);
|
||||
|
||||
|
||||
@@ -11,15 +11,11 @@
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject CommunicationService CommunicationService
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Debug View</h4>
|
||||
<div class="alert alert-info py-1 px-2 mb-0 small">
|
||||
Debug view streams are lost on failover. Re-open if connection drops.
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mb-3">Debug View</h4>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@@ -182,6 +178,24 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
|
||||
var storedSiteId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.siteId");
|
||||
var storedInstanceName = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.instanceName");
|
||||
|
||||
if (!string.IsNullOrEmpty(storedSiteId) && int.TryParse(storedSiteId, out var siteId)
|
||||
&& !string.IsNullOrEmpty(storedInstanceName))
|
||||
{
|
||||
_selectedSiteId = siteId;
|
||||
await LoadInstancesForSite();
|
||||
_selectedInstanceName = storedInstanceName;
|
||||
StateHasChanged();
|
||||
await Connect();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadInstancesForSite()
|
||||
{
|
||||
_siteInstances.Clear();
|
||||
@@ -224,6 +238,12 @@
|
||||
}
|
||||
|
||||
_connected = true;
|
||||
|
||||
// Persist selection to localStorage for auto-reconnect on refresh
|
||||
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteId", _selectedSiteId.ToString());
|
||||
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.instanceName", _selectedInstanceName);
|
||||
await JS.InvokeVoidAsync("localStorage.setItem", "debugView.siteIdentifier", site.SiteIdentifier);
|
||||
|
||||
_toast.ShowSuccess($"Connected to {_selectedInstanceName}");
|
||||
|
||||
// Periodic refresh (simulating SignalR push by re-subscribing)
|
||||
@@ -253,7 +273,7 @@
|
||||
_connecting = false;
|
||||
}
|
||||
|
||||
private void Disconnect()
|
||||
private async Task Disconnect()
|
||||
{
|
||||
_refreshTimer?.Dispose();
|
||||
_refreshTimer = null;
|
||||
@@ -268,6 +288,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Clear persisted selection — user explicitly disconnected
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteId");
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceName");
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.siteIdentifier");
|
||||
|
||||
_connected = false;
|
||||
_snapshot = null;
|
||||
_attributeValues.Clear();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.JSInterop
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using ScadaLink.CentralUI
|
||||
@using ScadaLink.CentralUI.Components.Layout
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
@@ -34,4 +36,27 @@ public record ExternalCallResult(
|
||||
bool Success,
|
||||
string? ResponseJson,
|
||||
string? ErrorMessage,
|
||||
bool WasBuffered = false);
|
||||
bool WasBuffered = false)
|
||||
{
|
||||
private dynamic? _response;
|
||||
private bool _responseParsed;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed response as a dynamic object. Returns null if ResponseJson is null or empty.
|
||||
/// Access properties directly: result.Response.result, result.Response.items[0].name, etc.
|
||||
/// </summary>
|
||||
public dynamic? Response
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_responseParsed)
|
||||
{
|
||||
_response = string.IsNullOrEmpty(ResponseJson)
|
||||
? null
|
||||
: new DynamicJsonElement(System.Text.Json.JsonDocument.Parse(ResponseJson).RootElement);
|
||||
_responseParsed = true;
|
||||
}
|
||||
return _response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,10 @@ public record GetExternalSystemCommand(int ExternalSystemId);
|
||||
public record CreateExternalSystemCommand(string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
|
||||
public record UpdateExternalSystemCommand(int ExternalSystemId, string Name, string EndpointUrl, string AuthType, string? AuthConfiguration);
|
||||
public record DeleteExternalSystemCommand(int ExternalSystemId);
|
||||
|
||||
// External System Methods
|
||||
public record ListExternalSystemMethodsCommand(int ExternalSystemId);
|
||||
public record GetExternalSystemMethodCommand(int MethodId);
|
||||
public record CreateExternalSystemMethodCommand(int ExternalSystemId, string Name, string HttpMethod, string Path, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record UpdateExternalSystemMethodCommand(int MethodId, string? Name = null, string? HttpMethod = null, string? Path = null, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record DeleteExternalSystemMethodCommand(int MethodId);
|
||||
|
||||
@@ -14,8 +14,8 @@ public record DeleteTemplateAttributeCommand(int AttributeId);
|
||||
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
|
||||
public record DeleteTemplateAlarmCommand(int AlarmId);
|
||||
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked);
|
||||
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked);
|
||||
public record AddTemplateScriptCommand(int TemplateId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record UpdateTemplateScriptCommand(int ScriptId, string Name, string Code, string? TriggerType, string? TriggerConfiguration, bool IsLocked, string? ParameterDefinitions = null, string? ReturnDefinition = null);
|
||||
public record DeleteTemplateScriptCommand(int ScriptId);
|
||||
public record AddTemplateCompositionCommand(int TemplateId, string InstanceName, int ComposedTemplateId);
|
||||
public record DeleteTemplateCompositionCommand(int CompositionId);
|
||||
|
||||
92
src/ScadaLink.Commons/Types/DynamicJsonElement.cs
Normal file
92
src/ScadaLink.Commons/Types/DynamicJsonElement.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a JsonElement as a dynamic object for convenient property access in scripts.
|
||||
/// Supports property access (obj.name), indexing (obj.items[0]), and ToString().
|
||||
/// </summary>
|
||||
public class DynamicJsonElement : DynamicObject
|
||||
{
|
||||
private readonly JsonElement _element;
|
||||
|
||||
public DynamicJsonElement(JsonElement element)
|
||||
{
|
||||
_element = element;
|
||||
}
|
||||
|
||||
public override bool TryGetMember(GetMemberBinder binder, out object? result)
|
||||
{
|
||||
if (_element.ValueKind == JsonValueKind.Object &&
|
||||
_element.TryGetProperty(binder.Name, out var prop))
|
||||
{
|
||||
result = Wrap(prop);
|
||||
return true;
|
||||
}
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object? result)
|
||||
{
|
||||
if (_element.ValueKind == JsonValueKind.Array &&
|
||||
indexes.Length == 1 && indexes[0] is int index)
|
||||
{
|
||||
var arrayLength = _element.GetArrayLength();
|
||||
if (index >= 0 && index < arrayLength)
|
||||
{
|
||||
result = Wrap(_element[index]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override bool TryConvert(ConvertBinder binder, out object? result)
|
||||
{
|
||||
result = ConvertTo(binder.Type);
|
||||
return result != null || binder.Type == typeof(object);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return _element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => _element.GetString() ?? "",
|
||||
JsonValueKind.Number => _element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "",
|
||||
_ => _element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private object? ConvertTo(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return ToString();
|
||||
if (type == typeof(int) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt32();
|
||||
if (type == typeof(long) && _element.ValueKind == JsonValueKind.Number) return _element.GetInt64();
|
||||
if (type == typeof(double) && _element.ValueKind == JsonValueKind.Number) return _element.GetDouble();
|
||||
if (type == typeof(decimal) && _element.ValueKind == JsonValueKind.Number) return _element.GetDecimal();
|
||||
if (type == typeof(bool) && (_element.ValueKind == JsonValueKind.True || _element.ValueKind == JsonValueKind.False))
|
||||
return _element.GetBoolean();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? Wrap(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => new DynamicJsonElement(element),
|
||||
JsonValueKind.Array => new DynamicJsonElement(element),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -60,5 +60,6 @@ public enum ValidationCategory
|
||||
ReturnTypeMismatch,
|
||||
TriggerOperandType,
|
||||
OnTriggerScriptNotFound,
|
||||
CrossCallViolation
|
||||
CrossCallViolation,
|
||||
MissingMetadata
|
||||
}
|
||||
|
||||
@@ -83,8 +83,18 @@ public class FlatteningPipeline : IFlatteningPipeline
|
||||
|
||||
var config = flattenResult.Value;
|
||||
|
||||
// Load shared scripts for semantic validation
|
||||
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
|
||||
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
|
||||
{
|
||||
CanonicalName = s.Name,
|
||||
Code = s.Code,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition
|
||||
}).ToList();
|
||||
|
||||
// Validate
|
||||
var validation = _validationService.Validate(config);
|
||||
var validation = _validationService.Validate(config, resolvedSharedScripts);
|
||||
|
||||
// Compute revision hash
|
||||
var hash = _revisionHashService.ComputeHash(config);
|
||||
|
||||
@@ -11,9 +11,11 @@ using ScadaLink.Communication.Actors;
|
||||
using ScadaLink.Host.Actors;
|
||||
using ScadaLink.SiteRuntime;
|
||||
using ScadaLink.SiteRuntime.Actors;
|
||||
using ScadaLink.SiteRuntime.Messages;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using ScadaLink.SiteRuntime.Streaming;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.Host.Actors;
|
||||
|
||||
@@ -231,6 +233,26 @@ akka {{
|
||||
// Resolve the health collector for the Deployment Manager
|
||||
var siteHealthCollector = _serviceProvider.GetService<ScadaLink.HealthMonitoring.ISiteHealthCollector>();
|
||||
|
||||
// Create SiteReplicationActor on every node (not a singleton)
|
||||
var sfStorage = _serviceProvider.GetRequiredService<StoreAndForwardStorage>();
|
||||
var replicationService = _serviceProvider.GetRequiredService<ReplicationService>();
|
||||
var replicationLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger<SiteReplicationActor>();
|
||||
|
||||
var replicationActor = _actorSystem!.ActorOf(
|
||||
Props.Create(() => new SiteReplicationActor(
|
||||
storage, sfStorage, replicationService, siteRole, replicationLogger)),
|
||||
"site-replication");
|
||||
|
||||
// Wire S&F replication handler to forward operations via the replication actor
|
||||
replicationService.SetReplicationHandler(op =>
|
||||
{
|
||||
replicationActor.Tell(new ReplicateStoreAndForward(op));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_logger.LogInformation("SiteReplicationActor created and S&F replication handler wired");
|
||||
|
||||
// Create the Deployment Manager as a cluster singleton
|
||||
var singletonProps = ClusterSingletonManager.Props(
|
||||
singletonProps: Props.Create(() => new DeploymentManagerActor(
|
||||
@@ -241,6 +263,7 @@ akka {{
|
||||
siteRuntimeOptionsValue,
|
||||
dmLogger,
|
||||
dclManager,
|
||||
replicationActor,
|
||||
siteHealthCollector,
|
||||
_serviceProvider)),
|
||||
terminationMessage: PoisonPill.Instance,
|
||||
@@ -267,6 +290,9 @@ akka {{
|
||||
dmProxy)),
|
||||
"site-communication");
|
||||
|
||||
// Register local handlers with SiteCommunicationActor
|
||||
siteCommActor.Tell(new RegisterLocalHandler(LocalHandlerType.Artifacts, dmProxy));
|
||||
|
||||
// Register SiteCommunicationActor with ClusterClientReceptionist so central ClusterClients can reach it
|
||||
ClusterClientReceptionist.Get(_actorSystem).RegisterService(siteCommActor);
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ public class ManagementActor : ReceiveActor
|
||||
or ValidateTemplateCommand
|
||||
or CreateExternalSystemCommand or UpdateExternalSystemCommand
|
||||
or DeleteExternalSystemCommand
|
||||
or CreateExternalSystemMethodCommand or UpdateExternalSystemMethodCommand
|
||||
or DeleteExternalSystemMethodCommand
|
||||
or CreateNotificationListCommand or UpdateNotificationListCommand
|
||||
or DeleteNotificationListCommand
|
||||
or UpdateSmtpConfigCommand
|
||||
@@ -178,6 +180,11 @@ public class ManagementActor : ReceiveActor
|
||||
CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd),
|
||||
UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd),
|
||||
DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd),
|
||||
ListExternalSystemMethodsCommand cmd => await HandleListExternalSystemMethods(sp, cmd),
|
||||
GetExternalSystemMethodCommand cmd => await HandleGetExternalSystemMethod(sp, cmd),
|
||||
CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd),
|
||||
UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd),
|
||||
DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd),
|
||||
|
||||
// Notification Lists
|
||||
ListNotificationListsCommand => await HandleListNotificationLists(sp),
|
||||
@@ -568,6 +575,55 @@ public class ManagementActor : ReceiveActor
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId);
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
var method = new ExternalSystemMethod(cmd.Name, cmd.HttpMethod, cmd.Path)
|
||||
{
|
||||
ExternalSystemDefinitionId = cmd.ExternalSystemId,
|
||||
ParameterDefinitions = cmd.ParameterDefinitions,
|
||||
ReturnDefinition = cmd.ReturnDefinition
|
||||
};
|
||||
await repo.AddExternalSystemMethodAsync(method);
|
||||
await repo.SaveChangesAsync();
|
||||
return method;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId)
|
||||
?? throw new InvalidOperationException($"ExternalSystemMethod with ID {cmd.MethodId} not found.");
|
||||
if (cmd.Name != null) method.Name = cmd.Name;
|
||||
if (cmd.HttpMethod != null) method.HttpMethod = cmd.HttpMethod;
|
||||
if (cmd.Path != null) method.Path = cmd.Path;
|
||||
if (cmd.ParameterDefinitions != null) method.ParameterDefinitions = cmd.ParameterDefinitions;
|
||||
if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition;
|
||||
await repo.UpdateExternalSystemMethodAsync(method);
|
||||
await repo.SaveChangesAsync();
|
||||
return method;
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
await repo.DeleteExternalSystemMethodAsync(cmd.MethodId);
|
||||
await repo.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Notification handlers
|
||||
// ========================================================================
|
||||
@@ -850,7 +906,9 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
TriggerType = cmd.TriggerType,
|
||||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||||
IsLocked = cmd.IsLocked
|
||||
IsLocked = cmd.IsLocked,
|
||||
ParameterDefinitions = cmd.ParameterDefinitions,
|
||||
ReturnDefinition = cmd.ReturnDefinition
|
||||
};
|
||||
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
|
||||
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
|
||||
@@ -863,7 +921,9 @@ public class ManagementActor : ReceiveActor
|
||||
{
|
||||
TriggerType = cmd.TriggerType,
|
||||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||||
IsLocked = cmd.IsLocked
|
||||
IsLocked = cmd.IsLocked,
|
||||
ParameterDefinitions = cmd.ParameterDefinitions,
|
||||
ReturnDefinition = cmd.ReturnDefinition
|
||||
};
|
||||
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
|
||||
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
|
||||
|
||||
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Messages.Deployment;
|
||||
using ScadaLink.Commons.Messages.Lifecycle;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.HealthMonitoring;
|
||||
using ScadaLink.SiteRuntime.Messages;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using ScadaLink.SiteRuntime.Streaming;
|
||||
@@ -31,6 +32,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
private readonly SiteRuntimeOptions _options;
|
||||
private readonly ILogger<DeploymentManagerActor> _logger;
|
||||
private readonly IActorRef? _dclManager;
|
||||
private readonly IActorRef? _replicationActor;
|
||||
private readonly ISiteHealthCollector? _healthCollector;
|
||||
private readonly IServiceProvider? _serviceProvider;
|
||||
private readonly Dictionary<string, IActorRef> _instanceActors = new();
|
||||
@@ -46,6 +48,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
SiteRuntimeOptions options,
|
||||
ILogger<DeploymentManagerActor> logger,
|
||||
IActorRef? dclManager = null,
|
||||
IActorRef? replicationActor = null,
|
||||
ISiteHealthCollector? healthCollector = null,
|
||||
IServiceProvider? serviceProvider = null)
|
||||
{
|
||||
@@ -55,6 +58,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
_streamManager = streamManager;
|
||||
_options = options;
|
||||
_dclManager = dclManager;
|
||||
_replicationActor = replicationActor;
|
||||
_healthCollector = healthCollector;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
@@ -238,6 +242,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
// Static overrides are reset on redeployment per design decision
|
||||
await _storage.ClearStaticOverridesAsync(instanceName);
|
||||
|
||||
// Replicate to standby node
|
||||
_replicationActor?.Tell(new ReplicateConfigDeploy(
|
||||
instanceName, command.FlattenedConfigurationJson,
|
||||
command.DeploymentId, command.RevisionHash, true));
|
||||
|
||||
return new DeployPersistenceResult(command.DeploymentId, instanceName, true, null, sender);
|
||||
}).ContinueWith(t =>
|
||||
{
|
||||
@@ -285,6 +294,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
var sender = Sender;
|
||||
_storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, false));
|
||||
|
||||
return new InstanceLifecycleResponse(
|
||||
command.CommandId,
|
||||
instanceName,
|
||||
@@ -308,6 +320,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await _storage.SetInstanceEnabledAsync(instanceName, true);
|
||||
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, true));
|
||||
var configs = await _storage.GetAllDeployedConfigsAsync();
|
||||
var config = configs.FirstOrDefault(c => c.InstanceUniqueName == instanceName);
|
||||
return new EnableResult(command, config, null, sender);
|
||||
@@ -365,6 +378,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
var sender = Sender;
|
||||
_storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
_replicationActor?.Tell(new ReplicateConfigRemove(instanceName));
|
||||
|
||||
return new InstanceLifecycleResponse(
|
||||
command.CommandId,
|
||||
instanceName,
|
||||
@@ -548,6 +564,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
// Replicate artifacts to standby node
|
||||
_replicationActor?.Tell(new ReplicateArtifacts(command));
|
||||
|
||||
return new ArtifactDeploymentResponse(
|
||||
command.DeploymentId, "", true, null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
"interval" => ParseIntervalTrigger(triggerConfigJson),
|
||||
"valuechange" => ParseValueChangeTrigger(triggerConfigJson),
|
||||
"conditional" => ParseConditionalTrigger(triggerConfigJson),
|
||||
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
214
src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs
Normal file
214
src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs
Normal file
@@ -0,0 +1,214 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Event;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.SiteRuntime.Messages;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Runs on every site node (not a singleton). Handles both config and S&F replication
|
||||
/// between site cluster peers.
|
||||
///
|
||||
/// Outbound: receives local replication requests and forwards to peer via ActorSelection.
|
||||
/// Inbound: receives replicated operations from peer and applies to local SQLite.
|
||||
/// Uses fire-and-forget (Tell) — no ack wait per design.
|
||||
/// </summary>
|
||||
public class SiteReplicationActor : ReceiveActor
|
||||
{
|
||||
private readonly SiteStorageService _storage;
|
||||
private readonly StoreAndForwardStorage _sfStorage;
|
||||
private readonly ReplicationService _replicationService;
|
||||
private readonly string _siteRole;
|
||||
private readonly ILogger<SiteReplicationActor> _logger;
|
||||
private readonly Cluster _cluster;
|
||||
private Address? _peerAddress;
|
||||
|
||||
public SiteReplicationActor(
|
||||
SiteStorageService storage,
|
||||
StoreAndForwardStorage sfStorage,
|
||||
ReplicationService replicationService,
|
||||
string siteRole,
|
||||
ILogger<SiteReplicationActor> logger)
|
||||
{
|
||||
_storage = storage;
|
||||
_sfStorage = sfStorage;
|
||||
_replicationService = replicationService;
|
||||
_siteRole = siteRole;
|
||||
_logger = logger;
|
||||
_cluster = Cluster.Get(Context.System);
|
||||
|
||||
// Cluster member events
|
||||
Receive<ClusterEvent.MemberUp>(HandleMemberUp);
|
||||
Receive<ClusterEvent.MemberRemoved>(HandleMemberRemoved);
|
||||
Receive<ClusterEvent.CurrentClusterState>(HandleCurrentClusterState);
|
||||
|
||||
// Outbound — forward to peer
|
||||
Receive<ReplicateConfigDeploy>(msg => SendToPeer(new ApplyConfigDeploy(
|
||||
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)));
|
||||
Receive<ReplicateConfigRemove>(msg => SendToPeer(new ApplyConfigRemove(msg.InstanceName)));
|
||||
Receive<ReplicateConfigSetEnabled>(msg => SendToPeer(new ApplyConfigSetEnabled(
|
||||
msg.InstanceName, msg.IsEnabled)));
|
||||
Receive<ReplicateArtifacts>(msg => SendToPeer(new ApplyArtifacts(msg.Command)));
|
||||
Receive<ReplicateStoreAndForward>(msg => SendToPeer(new ApplyStoreAndForward(msg.Operation)));
|
||||
|
||||
// Inbound — apply from peer
|
||||
Receive<ApplyConfigDeploy>(HandleApplyConfigDeploy);
|
||||
Receive<ApplyConfigRemove>(HandleApplyConfigRemove);
|
||||
Receive<ApplyConfigSetEnabled>(HandleApplyConfigSetEnabled);
|
||||
Receive<ApplyArtifacts>(HandleApplyArtifacts);
|
||||
Receive<ApplyStoreAndForward>(HandleApplyStoreAndForward);
|
||||
}
|
||||
|
||||
protected override void PreStart()
|
||||
{
|
||||
base.PreStart();
|
||||
_cluster.Subscribe(Self, ClusterEvent.SubscriptionInitialStateMode.InitialStateAsSnapshot,
|
||||
typeof(ClusterEvent.MemberUp),
|
||||
typeof(ClusterEvent.MemberRemoved));
|
||||
_logger.LogInformation("SiteReplicationActor started, subscribing to cluster events for role {Role}", _siteRole);
|
||||
}
|
||||
|
||||
protected override void PostStop()
|
||||
{
|
||||
_cluster.Unsubscribe(Self);
|
||||
base.PostStop();
|
||||
}
|
||||
|
||||
private void HandleCurrentClusterState(ClusterEvent.CurrentClusterState state)
|
||||
{
|
||||
foreach (var member in state.Members)
|
||||
{
|
||||
if (member.Status == MemberStatus.Up)
|
||||
TryTrackPeer(member);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleMemberUp(ClusterEvent.MemberUp evt)
|
||||
{
|
||||
TryTrackPeer(evt.Member);
|
||||
}
|
||||
|
||||
private void HandleMemberRemoved(ClusterEvent.MemberRemoved evt)
|
||||
{
|
||||
if (evt.Member.Address.Equals(_peerAddress))
|
||||
{
|
||||
_logger.LogInformation("Peer node removed: {Address}", _peerAddress);
|
||||
_peerAddress = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryTrackPeer(Member member)
|
||||
{
|
||||
// Must have our site role, and must not be self
|
||||
if (member.HasRole(_siteRole) && !member.Address.Equals(_cluster.SelfAddress))
|
||||
{
|
||||
_peerAddress = member.Address;
|
||||
_logger.LogInformation("Peer node tracked: {Address}", _peerAddress);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendToPeer(object message)
|
||||
{
|
||||
if (_peerAddress == null)
|
||||
{
|
||||
_logger.LogDebug("No peer available, dropping replication message {Type}", message.GetType().Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = new RootActorPath(_peerAddress) / "user" / "site-replication";
|
||||
Context.ActorSelection(path).Tell(message);
|
||||
}
|
||||
|
||||
// ── Inbound handlers ──
|
||||
|
||||
private void HandleApplyConfigDeploy(ApplyConfigDeploy msg)
|
||||
{
|
||||
_logger.LogInformation("Applying replicated config deploy for {Instance}", msg.InstanceName);
|
||||
_storage.StoreDeployedConfigAsync(
|
||||
msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled)
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "Failed to apply replicated deploy for {Instance}", msg.InstanceName);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleApplyConfigRemove(ApplyConfigRemove msg)
|
||||
{
|
||||
_logger.LogInformation("Applying replicated config remove for {Instance}", msg.InstanceName);
|
||||
_storage.RemoveDeployedConfigAsync(msg.InstanceName)
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "Failed to apply replicated remove for {Instance}", msg.InstanceName);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleApplyConfigSetEnabled(ApplyConfigSetEnabled msg)
|
||||
{
|
||||
_logger.LogInformation("Applying replicated set-enabled={Enabled} for {Instance}", msg.IsEnabled, msg.InstanceName);
|
||||
_storage.SetInstanceEnabledAsync(msg.InstanceName, msg.IsEnabled)
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "Failed to apply replicated set-enabled for {Instance}", msg.InstanceName);
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleApplyArtifacts(ApplyArtifacts msg)
|
||||
{
|
||||
var command = msg.Command;
|
||||
_logger.LogInformation("Applying replicated artifacts, deploymentId={DeploymentId}", command.DeploymentId);
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (command.SharedScripts != null)
|
||||
foreach (var s in command.SharedScripts)
|
||||
await _storage.StoreSharedScriptAsync(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition);
|
||||
|
||||
if (command.ExternalSystems != null)
|
||||
foreach (var es in command.ExternalSystems)
|
||||
await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson);
|
||||
|
||||
if (command.DatabaseConnections != null)
|
||||
foreach (var db in command.DatabaseConnections)
|
||||
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString, db.MaxRetries, db.RetryDelay);
|
||||
|
||||
if (command.NotificationLists != null)
|
||||
foreach (var nl in command.NotificationLists)
|
||||
await _storage.StoreNotificationListAsync(nl.Name, nl.RecipientEmails);
|
||||
|
||||
if (command.DataConnections != null)
|
||||
foreach (var dc in command.DataConnections)
|
||||
await _storage.StoreDataConnectionDefinitionAsync(dc.Name, dc.Protocol, dc.ConfigurationJson);
|
||||
|
||||
if (command.SmtpConfigurations != null)
|
||||
foreach (var smtp in command.SmtpConfigurations)
|
||||
await _storage.StoreSmtpConfigurationAsync(smtp.Name, smtp.Server, smtp.Port, smtp.AuthMode,
|
||||
smtp.FromAddress, smtp.Username, smtp.Password, smtp.OAuthConfig);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to apply replicated artifacts");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleApplyStoreAndForward(ApplyStoreAndForward msg)
|
||||
{
|
||||
_logger.LogDebug("Applying replicated S&F operation {OpType} for message {Id}",
|
||||
msg.Operation.OperationType, msg.Operation.MessageId);
|
||||
|
||||
_replicationService.ApplyReplicatedOperationAsync(msg.Operation, _sfStorage)
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
_logger.LogError(t.Exception, "Failed to apply replicated S&F operation {Id}", msg.Operation.MessageId);
|
||||
});
|
||||
}
|
||||
}
|
||||
34
src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs
Normal file
34
src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using ScadaLink.Commons.Messages.Artifacts;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound messages — sent by local DeploymentManagerActor/S&F service
|
||||
/// to the local SiteReplicationActor for forwarding to the peer node.
|
||||
/// </summary>
|
||||
public record ReplicateConfigDeploy(
|
||||
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
|
||||
|
||||
public record ReplicateConfigRemove(string InstanceName);
|
||||
|
||||
public record ReplicateConfigSetEnabled(string InstanceName, bool IsEnabled);
|
||||
|
||||
public record ReplicateArtifacts(DeployArtifactsCommand Command);
|
||||
|
||||
public record ReplicateStoreAndForward(ReplicationOperation Operation);
|
||||
|
||||
/// <summary>
|
||||
/// Inbound messages — received from the peer's SiteReplicationActor
|
||||
/// and applied to local SQLite storage.
|
||||
/// </summary>
|
||||
public record ApplyConfigDeploy(
|
||||
string InstanceName, string ConfigJson, string DeploymentId, string RevisionHash, bool IsEnabled);
|
||||
|
||||
public record ApplyConfigRemove(string InstanceName);
|
||||
|
||||
public record ApplyConfigSetEnabled(string InstanceName, bool IsEnabled);
|
||||
|
||||
public record ApplyArtifacts(DeployArtifactsCommand Command);
|
||||
|
||||
public record ApplyStoreAndForward(ReplicationOperation Operation);
|
||||
@@ -23,6 +23,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -108,7 +108,9 @@ public class ScriptCompilationService
|
||||
.WithReferences(
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Math).Assembly)
|
||||
typeof(Math).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(Commons.Types.DynamicJsonElement).Assembly)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
|
||||
@@ -104,6 +104,20 @@ public class SemanticValidator
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Call-type scripts have parameter definitions
|
||||
foreach (var script in configuration.Scripts)
|
||||
{
|
||||
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
|
||||
{
|
||||
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
|
||||
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
|
||||
script.CanonicalName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate alarm trigger operand types
|
||||
foreach (var alarm in configuration.Alarms)
|
||||
{
|
||||
|
||||
@@ -22,12 +22,15 @@ public class ConcurrencyTestDbContext : ScadaLinkDbContext
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite
|
||||
// Remove the shadow RowVersion property and add a visible ConcurrencyStamp
|
||||
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite.
|
||||
// SQLite can't auto-generate rowversion, so disable it and use Status as the token instead.
|
||||
modelBuilder.Entity<DeploymentRecord>(builder =>
|
||||
{
|
||||
// The shadow RowVersion property from the base config doesn't work in SQLite.
|
||||
// Instead, use Status as a concurrency token for the test.
|
||||
builder.Property(d => d.RowVersion)
|
||||
.IsRequired(false)
|
||||
.IsConcurrencyToken(false)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(d => d.Status).IsConcurrencyToken();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,8 +68,9 @@ public class SecurityRepositoryTests : IDisposable
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var designMappings = await _repository.GetMappingsByRoleAsync("Design");
|
||||
Assert.Single(designMappings);
|
||||
Assert.Equal("Designers", designMappings[0].LdapGroupName);
|
||||
// Seed data includes "SCADA-Designers" with role "Design", plus the one we added
|
||||
Assert.Equal(2, designMappings.Count);
|
||||
Assert.Contains(designMappings, m => m.LdapGroupName == "Designers");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test DbContext that maps DateTimeOffset to a sortable string format for SQLite.
|
||||
/// EF Core 10 SQLite provider does not support ORDER BY on DateTimeOffset columns.
|
||||
/// Test DbContext that adapts SQL Server-specific features for SQLite:
|
||||
/// - Maps DateTimeOffset to sortable ISO 8601 strings (SQLite has no native DateTimeOffset ORDER BY)
|
||||
/// - Replaces SQL Server RowVersion with a nullable byte[] column (SQLite can't auto-generate rowversion)
|
||||
/// </summary>
|
||||
public class SqliteTestDbContext : ScadaLinkDbContext
|
||||
{
|
||||
@@ -19,6 +21,16 @@ public class SqliteTestDbContext : ScadaLinkDbContext
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// SQLite cannot auto-generate SQL Server rowversion values.
|
||||
// Replace with a nullable byte[] column so inserts don't fail with NOT NULL constraint.
|
||||
modelBuilder.Entity<DeploymentRecord>(builder =>
|
||||
{
|
||||
builder.Property(d => d.RowVersion)
|
||||
.IsRequired(false)
|
||||
.IsConcurrencyToken(false)
|
||||
.ValueGeneratedNever();
|
||||
});
|
||||
|
||||
// Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works
|
||||
var converter = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("o"),
|
||||
|
||||
@@ -21,13 +21,7 @@ public class DbContextTests : IDisposable
|
||||
|
||||
public DbContextTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -429,6 +423,6 @@ public class MigrationHelperTests : IDisposable
|
||||
{
|
||||
// Verify the InitialCreate migration is detected as pending
|
||||
var pending = _context.Database.GetPendingMigrations().ToList();
|
||||
Assert.Contains(pending, m => m.Contains("InitialCreate"));
|
||||
Assert.Contains(pending, m => m.Contains("InitialSchema"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user