From e8df71ea644739192d60c7597100600152d43ce9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 22 Mar 2026 08:41:57 -0400 Subject: [PATCH] feat(cli): add --primary-config, --backup-config, --failover-retry-count to data connection commands Thread backup data connection fields through management command messages, ManagementActor handlers, SiteService, site-side SQLite storage, and deployment/replication actors. The old --configuration CLI flag is kept as a hidden alias for backwards compatibility. --- .../Commands/DataConnectionCommands.cs | 20 ++++++++++--- .../Management/DataConnectionCommands.cs | 4 +-- .../ManagementActor.cs | 9 +++++- .../Actors/DeploymentManagerActor.cs | 3 +- .../Actors/SiteReplicationActor.cs | 2 +- .../Persistence/SiteStorageService.cs | 29 +++++++++++++------ .../Services/SiteService.cs | 17 ++++++++--- .../Services/SiteServiceTests.cs | 2 +- 8 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs b/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs index 6ef8250..e9e6684 100644 --- a/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs +++ b/src/ScadaLink.CLI/Commands/DataConnectionCommands.cs @@ -38,22 +38,28 @@ public static class DataConnectionCommands var idOption = new Option("--id") { Description = "Data connection ID", Required = true }; var nameOption = new Option("--name") { Description = "Connection name", Required = true }; var protocolOption = new Option("--protocol") { Description = "Protocol", Required = true }; - var configOption = new Option("--configuration") { Description = "Primary configuration JSON" }; + var configOption = new Option("--primary-config", "--configuration") { Description = "Primary configuration JSON" }; + var backupConfigOption = new Option("--backup-config") { Description = "Backup configuration JSON" }; + var failoverRetryOption = new Option("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 }; var cmd = new Command("update") { Description = "Update a data connection" }; cmd.Add(idOption); cmd.Add(nameOption); cmd.Add(protocolOption); cmd.Add(configOption); + cmd.Add(backupConfigOption); + cmd.Add(failoverRetryOption); cmd.SetAction(async (ParseResult result) => { var id = result.GetValue(idOption); var name = result.GetValue(nameOption)!; var protocol = result.GetValue(protocolOption)!; var config = result.GetValue(configOption); + var backupConfig = result.GetValue(backupConfigOption); + var failoverRetryCount = result.GetValue(failoverRetryOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new UpdateDataConnectionCommand(id, name, protocol, config)); + new UpdateDataConnectionCommand(id, name, protocol, config, backupConfig, failoverRetryCount)); }); return cmd; } @@ -77,22 +83,28 @@ public static class DataConnectionCommands var siteIdOption = new Option("--site-id") { Description = "Site ID", Required = true }; var nameOption = new Option("--name") { Description = "Connection name", Required = true }; var protocolOption = new Option("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true }; - var configOption = new Option("--configuration") { Description = "Primary configuration JSON" }; + var configOption = new Option("--primary-config", "--configuration") { Description = "Primary configuration JSON" }; + var backupConfigOption = new Option("--backup-config") { Description = "Backup configuration JSON" }; + var failoverRetryOption = new Option("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 }; var cmd = new Command("create") { Description = "Create a new data connection" }; cmd.Add(siteIdOption); cmd.Add(nameOption); cmd.Add(protocolOption); cmd.Add(configOption); + cmd.Add(backupConfigOption); + cmd.Add(failoverRetryOption); cmd.SetAction(async (ParseResult result) => { var siteId = result.GetValue(siteIdOption); var name = result.GetValue(nameOption)!; var protocol = result.GetValue(protocolOption)!; var config = result.GetValue(configOption); + var backupConfig = result.GetValue(backupConfigOption); + var failoverRetryCount = result.GetValue(failoverRetryOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, - new CreateDataConnectionCommand(siteId, name, protocol, config)); + new CreateDataConnectionCommand(siteId, name, protocol, config, backupConfig, failoverRetryCount)); }); return cmd; } diff --git a/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs b/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs index 7ea35ef..0d55b7e 100644 --- a/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs +++ b/src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs @@ -2,6 +2,6 @@ namespace ScadaLink.Commons.Messages.Management; public record ListDataConnectionsCommand(int? SiteId = null); public record GetDataConnectionCommand(int DataConnectionId); -public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? PrimaryConfiguration); -public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration); +public record CreateDataConnectionCommand(int SiteId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3); +public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3); public record DeleteDataConnectionCommand(int DataConnectionId); diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 435be26..5527acc 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -689,7 +689,12 @@ public class ManagementActor : ReceiveActor private static async Task HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user) { var repo = sp.GetRequiredService(); - var conn = new DataConnection(cmd.Name, cmd.Protocol, cmd.SiteId) { PrimaryConfiguration = cmd.PrimaryConfiguration }; + var conn = new DataConnection(cmd.Name, cmd.Protocol, cmd.SiteId) + { + PrimaryConfiguration = cmd.PrimaryConfiguration, + BackupConfiguration = cmd.BackupConfiguration, + FailoverRetryCount = cmd.FailoverRetryCount + }; await repo.AddDataConnectionAsync(conn); await repo.SaveChangesAsync(); await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn); @@ -704,6 +709,8 @@ public class ManagementActor : ReceiveActor conn.Name = cmd.Name; conn.Protocol = cmd.Protocol; conn.PrimaryConfiguration = cmd.PrimaryConfiguration; + conn.BackupConfiguration = cmd.BackupConfiguration; + conn.FailoverRetryCount = cmd.FailoverRetryCount; await repo.UpdateDataConnectionAsync(conn); await repo.SaveChangesAsync(); await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, conn); diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index 65c7cee..9837dca 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -630,7 +630,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers foreach (var dc in command.DataConnections) { await _storage.StoreDataConnectionDefinitionAsync( - dc.Name, dc.Protocol, dc.PrimaryConfigurationJson); + dc.Name, dc.Protocol, dc.PrimaryConfigurationJson, + dc.BackupConfigurationJson, dc.FailoverRetryCount); } } diff --git a/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs b/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs index 1b79f26..2f3a7a6 100644 --- a/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/SiteReplicationActor.cs @@ -185,7 +185,7 @@ public class SiteReplicationActor : ReceiveActor if (command.DataConnections != null) foreach (var dc in command.DataConnections) - await _storage.StoreDataConnectionDefinitionAsync(dc.Name, dc.Protocol, dc.PrimaryConfigurationJson); + await _storage.StoreDataConnectionDefinitionAsync(dc.Name, dc.Protocol, dc.PrimaryConfigurationJson, dc.BackupConfigurationJson, dc.FailoverRetryCount); if (command.SmtpConfigurations != null) foreach (var smtp in command.SmtpConfigurations) diff --git a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs index 5a87aee..99cee09 100644 --- a/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs +++ b/src/ScadaLink.SiteRuntime/Persistence/SiteStorageService.cs @@ -79,10 +79,12 @@ public class SiteStorageService ); CREATE TABLE IF NOT EXISTS data_connection_definitions ( - name TEXT PRIMARY KEY, - protocol TEXT NOT NULL, - configuration TEXT, - updated_at TEXT NOT NULL + name TEXT PRIMARY KEY, + protocol TEXT NOT NULL, + configuration TEXT, + backup_configuration TEXT, + failover_retry_count INTEGER NOT NULL DEFAULT 3, + updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS smtp_configurations ( @@ -480,23 +482,28 @@ public class SiteStorageService /// /// Stores or updates a data connection definition (OPC UA endpoint, etc.). /// - public async Task StoreDataConnectionDefinitionAsync(string name, string protocol, string? configJson) + public async Task StoreDataConnectionDefinitionAsync( + string name, string protocol, string? configJson, string? backupConfigJson = null, int failoverRetryCount = 3) { await using var connection = new SqliteConnection(_connectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = @" - INSERT INTO data_connection_definitions (name, protocol, configuration, updated_at) - VALUES (@name, @protocol, @config, @updatedAt) + INSERT INTO data_connection_definitions (name, protocol, configuration, backup_configuration, failover_retry_count, updated_at) + VALUES (@name, @protocol, @config, @backupConfig, @failoverRetryCount, @updatedAt) ON CONFLICT(name) DO UPDATE SET protocol = excluded.protocol, configuration = excluded.configuration, + backup_configuration = excluded.backup_configuration, + failover_retry_count = excluded.failover_retry_count, updated_at = excluded.updated_at"; command.Parameters.AddWithValue("@name", name); command.Parameters.AddWithValue("@protocol", protocol); command.Parameters.AddWithValue("@config", (object?)configJson ?? DBNull.Value); + command.Parameters.AddWithValue("@backupConfig", (object?)backupConfigJson ?? DBNull.Value); + command.Parameters.AddWithValue("@failoverRetryCount", failoverRetryCount); command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O")); await command.ExecuteNonQueryAsync(); @@ -512,7 +519,7 @@ public class SiteStorageService await connection.OpenAsync(); await using var command = connection.CreateCommand(); - command.CommandText = "SELECT name, protocol, configuration FROM data_connection_definitions"; + command.CommandText = "SELECT name, protocol, configuration, backup_configuration, failover_retry_count FROM data_connection_definitions"; var results = new List(); await using var reader = await command.ExecuteReaderAsync(); @@ -522,7 +529,9 @@ public class SiteStorageService { Name = reader.GetString(0), Protocol = reader.GetString(1), - ConfigurationJson = reader.IsDBNull(2) ? null : reader.GetString(2) + ConfigurationJson = reader.IsDBNull(2) ? null : reader.GetString(2), + BackupConfigurationJson = reader.IsDBNull(3) ? null : reader.GetString(3), + FailoverRetryCount = reader.GetInt32(4) }); } @@ -562,4 +571,6 @@ public class StoredDataConnectionDefinition public string Name { get; init; } = string.Empty; public string Protocol { get; init; } = string.Empty; public string? ConfigurationJson { get; init; } + public string? BackupConfigurationJson { get; init; } + public int FailoverRetryCount { get; init; } = 3; } diff --git a/src/ScadaLink.TemplateEngine/Services/SiteService.cs b/src/ScadaLink.TemplateEngine/Services/SiteService.cs index 51cf7f7..1f44d5f 100644 --- a/src/ScadaLink.TemplateEngine/Services/SiteService.cs +++ b/src/ScadaLink.TemplateEngine/Services/SiteService.cs @@ -96,7 +96,8 @@ public class SiteService // --- Data Connection CRUD --- public async Task> CreateDataConnectionAsync( - int siteId, string name, string protocol, string? configuration, string user, + int siteId, string name, string protocol, string? primaryConfiguration, + string? backupConfiguration, int failoverRetryCount, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) @@ -104,7 +105,12 @@ public class SiteService if (string.IsNullOrWhiteSpace(protocol)) return Result.Failure("Protocol is required."); - var connection = new DataConnection(name, protocol, siteId) { PrimaryConfiguration = configuration }; + var connection = new DataConnection(name, protocol, siteId) + { + PrimaryConfiguration = primaryConfiguration, + BackupConfiguration = backupConfiguration, + FailoverRetryCount = failoverRetryCount + }; await _repository.AddDataConnectionAsync(connection, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); @@ -115,7 +121,8 @@ public class SiteService } public async Task> UpdateDataConnectionAsync( - int connectionId, string name, string protocol, string? configuration, string user, + int connectionId, string name, string protocol, string? primaryConfiguration, + string? backupConfiguration, int failoverRetryCount, string user, CancellationToken cancellationToken = default) { var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken); @@ -124,7 +131,9 @@ public class SiteService connection.Name = name; connection.Protocol = protocol; - connection.PrimaryConfiguration = configuration; + connection.PrimaryConfiguration = primaryConfiguration; + connection.BackupConfiguration = backupConfiguration; + connection.FailoverRetryCount = failoverRetryCount; await _repository.UpdateDataConnectionAsync(connection, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); diff --git a/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs index 01c17aa..8faa4c4 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/Services/SiteServiceTests.cs @@ -93,7 +93,7 @@ public class SiteServiceTests _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny())) .ReturnsAsync(1); - var result = await _sut.CreateDataConnectionAsync(1, "OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", "admin"); + var result = await _sut.CreateDataConnectionAsync(1, "OPC-Server1", "OpcUa", "{\"url\":\"opc.tcp://localhost\"}", null, 3, "admin"); Assert.True(result.IsSuccess); Assert.Equal("OPC-Server1", result.Value.Name);