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.
This commit is contained in:
Joseph Doherty
2026-03-22 08:41:57 -04:00
parent ab4e88f17f
commit e8df71ea64
8 changed files with 63 additions and 23 deletions

View File

@@ -38,22 +38,28 @@ public static class DataConnectionCommands
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true }; var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true }; var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol", Required = true }; var protocolOption = new Option<string>("--protocol") { Description = "Protocol", Required = true };
var configOption = new Option<string?>("--configuration") { Description = "Primary configuration JSON" }; var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
var cmd = new Command("update") { Description = "Update a data connection" }; var cmd = new Command("update") { Description = "Update a data connection" };
cmd.Add(idOption); cmd.Add(idOption);
cmd.Add(nameOption); cmd.Add(nameOption);
cmd.Add(protocolOption); cmd.Add(protocolOption);
cmd.Add(configOption); cmd.Add(configOption);
cmd.Add(backupConfigOption);
cmd.Add(failoverRetryOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var id = result.GetValue(idOption); var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!; var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!; var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption); var config = result.GetValue(configOption);
var backupConfig = result.GetValue(backupConfigOption);
var failoverRetryCount = result.GetValue(failoverRetryOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateDataConnectionCommand(id, name, protocol, config)); new UpdateDataConnectionCommand(id, name, protocol, config, backupConfig, failoverRetryCount));
}); });
return cmd; return cmd;
} }
@@ -77,22 +83,28 @@ public static class DataConnectionCommands
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true }; var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true }; var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true }; var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
var configOption = new Option<string?>("--configuration") { Description = "Primary configuration JSON" }; var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
var cmd = new Command("create") { Description = "Create a new data connection" }; var cmd = new Command("create") { Description = "Create a new data connection" };
cmd.Add(siteIdOption); cmd.Add(siteIdOption);
cmd.Add(nameOption); cmd.Add(nameOption);
cmd.Add(protocolOption); cmd.Add(protocolOption);
cmd.Add(configOption); cmd.Add(configOption);
cmd.Add(backupConfigOption);
cmd.Add(failoverRetryOption);
cmd.SetAction(async (ParseResult result) => cmd.SetAction(async (ParseResult result) =>
{ {
var siteId = result.GetValue(siteIdOption); var siteId = result.GetValue(siteIdOption);
var name = result.GetValue(nameOption)!; var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!; var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption); var config = result.GetValue(configOption);
var backupConfig = result.GetValue(backupConfigOption);
var failoverRetryCount = result.GetValue(failoverRetryOption);
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new CreateDataConnectionCommand(siteId, name, protocol, config)); new CreateDataConnectionCommand(siteId, name, protocol, config, backupConfig, failoverRetryCount));
}); });
return cmd; return cmd;
} }

View File

@@ -2,6 +2,6 @@ namespace ScadaLink.Commons.Messages.Management;
public record ListDataConnectionsCommand(int? SiteId = null); public record ListDataConnectionsCommand(int? SiteId = null);
public record GetDataConnectionCommand(int DataConnectionId); public record GetDataConnectionCommand(int DataConnectionId);
public record CreateDataConnectionCommand(int SiteId, 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); public record UpdateDataConnectionCommand(int DataConnectionId, string Name, string Protocol, string? PrimaryConfiguration, string? BackupConfiguration = null, int FailoverRetryCount = 3);
public record DeleteDataConnectionCommand(int DataConnectionId); public record DeleteDataConnectionCommand(int DataConnectionId);

View File

@@ -689,7 +689,12 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user) private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
{ {
var repo = sp.GetRequiredService<ISiteRepository>(); var repo = sp.GetRequiredService<ISiteRepository>();
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.AddDataConnectionAsync(conn);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn); 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.Name = cmd.Name;
conn.Protocol = cmd.Protocol; conn.Protocol = cmd.Protocol;
conn.PrimaryConfiguration = cmd.PrimaryConfiguration; conn.PrimaryConfiguration = cmd.PrimaryConfiguration;
conn.BackupConfiguration = cmd.BackupConfiguration;
conn.FailoverRetryCount = cmd.FailoverRetryCount;
await repo.UpdateDataConnectionAsync(conn); await repo.UpdateDataConnectionAsync(conn);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, conn); await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, conn);

View File

@@ -630,7 +630,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
foreach (var dc in command.DataConnections) foreach (var dc in command.DataConnections)
{ {
await _storage.StoreDataConnectionDefinitionAsync( await _storage.StoreDataConnectionDefinitionAsync(
dc.Name, dc.Protocol, dc.PrimaryConfigurationJson); dc.Name, dc.Protocol, dc.PrimaryConfigurationJson,
dc.BackupConfigurationJson, dc.FailoverRetryCount);
} }
} }

View File

@@ -185,7 +185,7 @@ public class SiteReplicationActor : ReceiveActor
if (command.DataConnections != null) if (command.DataConnections != null)
foreach (var dc in command.DataConnections) 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) if (command.SmtpConfigurations != null)
foreach (var smtp in command.SmtpConfigurations) foreach (var smtp in command.SmtpConfigurations)

View File

@@ -79,10 +79,12 @@ public class SiteStorageService
); );
CREATE TABLE IF NOT EXISTS data_connection_definitions ( CREATE TABLE IF NOT EXISTS data_connection_definitions (
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
protocol TEXT NOT NULL, protocol TEXT NOT NULL,
configuration TEXT, configuration TEXT,
updated_at TEXT NOT NULL backup_configuration TEXT,
failover_retry_count INTEGER NOT NULL DEFAULT 3,
updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS smtp_configurations ( CREATE TABLE IF NOT EXISTS smtp_configurations (
@@ -480,23 +482,28 @@ public class SiteStorageService
/// <summary> /// <summary>
/// Stores or updates a data connection definition (OPC UA endpoint, etc.). /// Stores or updates a data connection definition (OPC UA endpoint, etc.).
/// </summary> /// </summary>
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 using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync(); await connection.OpenAsync();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
INSERT INTO data_connection_definitions (name, protocol, configuration, updated_at) INSERT INTO data_connection_definitions (name, protocol, configuration, backup_configuration, failover_retry_count, updated_at)
VALUES (@name, @protocol, @config, @updatedAt) VALUES (@name, @protocol, @config, @backupConfig, @failoverRetryCount, @updatedAt)
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
protocol = excluded.protocol, protocol = excluded.protocol,
configuration = excluded.configuration, configuration = excluded.configuration,
backup_configuration = excluded.backup_configuration,
failover_retry_count = excluded.failover_retry_count,
updated_at = excluded.updated_at"; updated_at = excluded.updated_at";
command.Parameters.AddWithValue("@name", name); command.Parameters.AddWithValue("@name", name);
command.Parameters.AddWithValue("@protocol", protocol); command.Parameters.AddWithValue("@protocol", protocol);
command.Parameters.AddWithValue("@config", (object?)configJson ?? DBNull.Value); 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")); command.Parameters.AddWithValue("@updatedAt", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
@@ -512,7 +519,7 @@ public class SiteStorageService
await connection.OpenAsync(); await connection.OpenAsync();
await using var command = connection.CreateCommand(); 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<StoredDataConnectionDefinition>(); var results = new List<StoredDataConnectionDefinition>();
await using var reader = await command.ExecuteReaderAsync(); await using var reader = await command.ExecuteReaderAsync();
@@ -522,7 +529,9 @@ public class SiteStorageService
{ {
Name = reader.GetString(0), Name = reader.GetString(0),
Protocol = reader.GetString(1), 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 Name { get; init; } = string.Empty;
public string Protocol { get; init; } = string.Empty; public string Protocol { get; init; } = string.Empty;
public string? ConfigurationJson { get; init; } public string? ConfigurationJson { get; init; }
public string? BackupConfigurationJson { get; init; }
public int FailoverRetryCount { get; init; } = 3;
} }

View File

@@ -96,7 +96,8 @@ public class SiteService
// --- Data Connection CRUD --- // --- Data Connection CRUD ---
public async Task<Result<DataConnection>> CreateDataConnectionAsync( public async Task<Result<DataConnection>> 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) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -104,7 +105,12 @@ public class SiteService
if (string.IsNullOrWhiteSpace(protocol)) if (string.IsNullOrWhiteSpace(protocol))
return Result<DataConnection>.Failure("Protocol is required."); return Result<DataConnection>.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.AddDataConnectionAsync(connection, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken); await _repository.SaveChangesAsync(cancellationToken);
@@ -115,7 +121,8 @@ public class SiteService
} }
public async Task<Result<DataConnection>> UpdateDataConnectionAsync( public async Task<Result<DataConnection>> 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) CancellationToken cancellationToken = default)
{ {
var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken); var connection = await _repository.GetDataConnectionByIdAsync(connectionId, cancellationToken);
@@ -124,7 +131,9 @@ public class SiteService
connection.Name = name; connection.Name = name;
connection.Protocol = protocol; connection.Protocol = protocol;
connection.PrimaryConfiguration = configuration; connection.PrimaryConfiguration = primaryConfiguration;
connection.BackupConfiguration = backupConfiguration;
connection.FailoverRetryCount = failoverRetryCount;
await _repository.UpdateDataConnectionAsync(connection, cancellationToken); await _repository.UpdateDataConnectionAsync(connection, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken); await _repository.SaveChangesAsync(cancellationToken);

View File

@@ -93,7 +93,7 @@ public class SiteServiceTests
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())) _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1); .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.True(result.IsSuccess);
Assert.Equal("OPC-Server1", result.Value.Name); Assert.Equal("OPC-Server1", result.Value.Name);