From eb8ead58d2becd6dcf4f5e8d72cac27060b76ea3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 18 Mar 2026 08:28:02 -0400 Subject: [PATCH] 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. --- .../Commands/ExternalSystemCommands.cs | 118 ++++++++++ .../Commands/TemplateCommands.cs | 18 +- .../Pages/Deployment/DebugView.razor | 39 +++- src/ScadaLink.CentralUI/_Imports.razor | 1 + .../Services/IExternalSystemClient.cs | 27 ++- .../Management/ExternalSystemCommands.cs | 7 + .../Messages/Management/TemplateCommands.cs | 4 +- .../Types/DynamicJsonElement.cs | 92 ++++++++ .../Types/Flattening/ValidationResult.cs | 3 +- .../FlatteningPipeline.cs | 12 +- .../Actors/AkkaHostedService.cs | 26 +++ .../ManagementActor.cs | 64 +++++- .../Actors/DeploymentManagerActor.cs | 19 ++ .../Actors/ScriptActor.cs | 1 + .../Actors/SiteReplicationActor.cs | 214 ++++++++++++++++++ .../Messages/ReplicationMessages.cs | 34 +++ .../ScadaLink.SiteRuntime.csproj | 1 + .../Scripts/ScriptCompilationService.cs | 4 +- .../Validation/SemanticValidator.cs | 14 ++ .../ConcurrencyTests.cs | 11 +- .../RepositoryTests.cs | 5 +- .../SqliteTestHelper.cs | 16 +- .../UnitTest1.cs | 10 +- 23 files changed, 707 insertions(+), 33 deletions(-) create mode 100644 src/ScadaLink.Commons/Types/DynamicJsonElement.cs create mode 100644 src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs create mode 100644 src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs diff --git a/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs b/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs index a2f35b2..45a75c8 100644 --- a/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs +++ b/src/ScadaLink.CLI/Commands/ExternalSystemCommands.cs @@ -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 contactPointsOption, Option 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 contactPointsOption, Option formatOption) + { + var sysIdOption = new Option("--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 contactPointsOption, Option formatOption) + { + var idOption = new Option("--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 contactPointsOption, Option formatOption) + { + var sysIdOption = new Option("--external-system-id") { Description = "External system ID", Required = true }; + var nameOption = new Option("--name") { Description = "Method name", Required = true }; + var httpMethodOption = new Option("--http-method") { Description = "HTTP method (GET, POST, PUT, DELETE)", Required = true }; + var pathOption = new Option("--path") { Description = "URL path (e.g. /api/Add)", Required = true }; + var paramsOption = new Option("--params") { Description = "Parameter definitions JSON" }; + var returnOption = new Option("--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 contactPointsOption, Option formatOption) + { + var idOption = new Option("--id") { Description = "Method ID", Required = true }; + var nameOption = new Option("--name") { Description = "Method name" }; + var httpMethodOption = new Option("--http-method") { Description = "HTTP method" }; + var pathOption = new Option("--path") { Description = "URL path" }; + var paramsOption = new Option("--params") { Description = "Parameter definitions JSON" }; + var returnOption = new Option("--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 contactPointsOption, Option formatOption) + { + var idOption = new Option("--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; + } } diff --git a/src/ScadaLink.CLI/Commands/TemplateCommands.cs b/src/ScadaLink.CLI/Commands/TemplateCommands.cs index 07684bf..4a6e362 100644 --- a/src/ScadaLink.CLI/Commands/TemplateCommands.cs +++ b/src/ScadaLink.CLI/Commands/TemplateCommands.cs @@ -300,6 +300,9 @@ public static class TemplateCommands var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; + var paramsOption = new Option("--parameters") { Description = "Parameter definitions JSON" }; + var returnOption = new Option("--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("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; + var updateParamsOption = new Option("--parameters") { Description = "Parameter definitions JSON" }; + var updateReturnOption = new Option("--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); diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor index 3dc6f2b..b3adf27 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -11,15 +11,11 @@ @inject ITemplateEngineRepository TemplateEngineRepository @inject ISiteRepository SiteRepository @inject CommunicationService CommunicationService +@inject IJSRuntime JS @implements IDisposable
-
-

Debug View

-
- Debug view streams are lost on failover. Re-open if connection drops. -
-
+

Debug View

@@ -182,6 +178,24 @@ _loading = false; } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + var storedSiteId = await JS.InvokeAsync("localStorage.getItem", "debugView.siteId"); + var storedInstanceName = await JS.InvokeAsync("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(); diff --git a/src/ScadaLink.CentralUI/_Imports.razor b/src/ScadaLink.CentralUI/_Imports.razor index c7695c0..6645a64 100644 --- a/src/ScadaLink.CentralUI/_Imports.razor +++ b/src/ScadaLink.CentralUI/_Imports.razor @@ -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 diff --git a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs index 2d9af02..c875ebf 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs @@ -1,3 +1,5 @@ +using ScadaLink.Commons.Types; + namespace ScadaLink.Commons.Interfaces.Services; /// @@ -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; + + /// + /// 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. + /// + public dynamic? Response + { + get + { + if (!_responseParsed) + { + _response = string.IsNullOrEmpty(ResponseJson) + ? null + : new DynamicJsonElement(System.Text.Json.JsonDocument.Parse(ResponseJson).RootElement); + _responseParsed = true; + } + return _response; + } + } +} diff --git a/src/ScadaLink.Commons/Messages/Management/ExternalSystemCommands.cs b/src/ScadaLink.Commons/Messages/Management/ExternalSystemCommands.cs index 9dbd386..b16c8e8 100644 --- a/src/ScadaLink.Commons/Messages/Management/ExternalSystemCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/ExternalSystemCommands.cs @@ -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); diff --git a/src/ScadaLink.Commons/Messages/Management/TemplateCommands.cs b/src/ScadaLink.Commons/Messages/Management/TemplateCommands.cs index 7195476..8384088 100644 --- a/src/ScadaLink.Commons/Messages/Management/TemplateCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/TemplateCommands.cs @@ -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); diff --git a/src/ScadaLink.Commons/Types/DynamicJsonElement.cs b/src/ScadaLink.Commons/Types/DynamicJsonElement.cs new file mode 100644 index 0000000..07e0ece --- /dev/null +++ b/src/ScadaLink.Commons/Types/DynamicJsonElement.cs @@ -0,0 +1,92 @@ +using System.Dynamic; +using System.Text.Json; + +namespace ScadaLink.Commons.Types; + +/// +/// Wraps a JsonElement as a dynamic object for convenient property access in scripts. +/// Supports property access (obj.name), indexing (obj.items[0]), and ToString(). +/// +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() + }; + } +} diff --git a/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs b/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs index 1414407..34997b4 100644 --- a/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs +++ b/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs @@ -60,5 +60,6 @@ public enum ValidationCategory ReturnTypeMismatch, TriggerOperandType, OnTriggerScriptNotFound, - CrossCallViolation + CrossCallViolation, + MissingMetadata } diff --git a/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs b/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs index 36087a3..cbd7a3a 100644 --- a/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs +++ b/src/ScadaLink.DeploymentManager/FlatteningPipeline.cs @@ -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); diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs index 4d2acaa..b8b8a8c 100644 --- a/src/ScadaLink.Host/Actors/AkkaHostedService.cs +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -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(); + // Create SiteReplicationActor on every node (not a singleton) + var sfStorage = _serviceProvider.GetRequiredService(); + var replicationService = _serviceProvider.GetRequiredService(); + var replicationLogger = _serviceProvider.GetRequiredService() + .CreateLogger(); + + 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); diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 538d286..9e1f050 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -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 HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd) + { + var repo = sp.GetRequiredService(); + return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId); + } + + private static async Task HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd) + { + var repo = sp.GetRequiredService(); + return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId); + } + + private static async Task HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd) + { + var repo = sp.GetRequiredService(); + 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 HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd) + { + var repo = sp.GetRequiredService(); + 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 HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd) + { + var repo = sp.GetRequiredService(); + 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); diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index 7e1b46b..52f81e4 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -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 _logger; private readonly IActorRef? _dclManager; + private readonly IActorRef? _replicationActor; private readonly ISiteHealthCollector? _healthCollector; private readonly IServiceProvider? _serviceProvider; private readonly Dictionary _instanceActors = new(); @@ -46,6 +48,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers SiteRuntimeOptions options, ILogger 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); } diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs index ef0a872..52e4339 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs @@ -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 }; } diff --git a/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs b/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs new file mode 100644 index 0000000..ba3e736 --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs @@ -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; + +/// +/// 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. +/// +public class SiteReplicationActor : ReceiveActor +{ + private readonly SiteStorageService _storage; + private readonly StoreAndForwardStorage _sfStorage; + private readonly ReplicationService _replicationService; + private readonly string _siteRole; + private readonly ILogger _logger; + private readonly Cluster _cluster; + private Address? _peerAddress; + + public SiteReplicationActor( + SiteStorageService storage, + StoreAndForwardStorage sfStorage, + ReplicationService replicationService, + string siteRole, + ILogger logger) + { + _storage = storage; + _sfStorage = sfStorage; + _replicationService = replicationService; + _siteRole = siteRole; + _logger = logger; + _cluster = Cluster.Get(Context.System); + + // Cluster member events + Receive(HandleMemberUp); + Receive(HandleMemberRemoved); + Receive(HandleCurrentClusterState); + + // Outbound — forward to peer + Receive(msg => SendToPeer(new ApplyConfigDeploy( + msg.InstanceName, msg.ConfigJson, msg.DeploymentId, msg.RevisionHash, msg.IsEnabled))); + Receive(msg => SendToPeer(new ApplyConfigRemove(msg.InstanceName))); + Receive(msg => SendToPeer(new ApplyConfigSetEnabled( + msg.InstanceName, msg.IsEnabled))); + Receive(msg => SendToPeer(new ApplyArtifacts(msg.Command))); + Receive(msg => SendToPeer(new ApplyStoreAndForward(msg.Operation))); + + // Inbound — apply from peer + Receive(HandleApplyConfigDeploy); + Receive(HandleApplyConfigRemove); + Receive(HandleApplyConfigSetEnabled); + Receive(HandleApplyArtifacts); + Receive(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); + }); + } +} diff --git a/src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs b/src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs new file mode 100644 index 0000000..080458c --- /dev/null +++ b/src/ScadaLink.SiteRuntime/Messages/ReplicationMessages.cs @@ -0,0 +1,34 @@ +using ScadaLink.Commons.Messages.Artifacts; +using ScadaLink.StoreAndForward; + +namespace ScadaLink.SiteRuntime.Messages; + +/// +/// Outbound messages — sent by local DeploymentManagerActor/S&F service +/// to the local SiteReplicationActor for forwarding to the peer node. +/// +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); + +/// +/// Inbound messages — received from the peer's SiteReplicationActor +/// and applied to local SQLite storage. +/// +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); diff --git a/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj b/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj index a132fe8..0b689d4 100644 --- a/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj +++ b/src/ScadaLink.SiteRuntime/ScadaLink.SiteRuntime.csproj @@ -23,6 +23,7 @@ + diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs index c49b79e..16cb0e3 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs @@ -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", diff --git a/src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs b/src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs index 4a925bf..5b29b48 100644 --- a/src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs +++ b/src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs @@ -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) { diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs index 8db5d31..da98385 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ConcurrencyTests.cs @@ -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(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(); }); } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs index e6175b8..aa6ba9c 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryTests.cs @@ -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] diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs index d5551e7..026f8ca 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/SqliteTestHelper.cs @@ -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; /// -/// 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) /// 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(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( v => v.UtcDateTime.ToString("o"), diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs index f1a9110..78ffe44 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/UnitTest1.cs @@ -21,13 +21,7 @@ public class DbContextTests : IDisposable public DbContextTests() { - var options = new DbContextOptionsBuilder() - .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")); } }