Add configurable non-transparent OPC UA server redundancy

Separates ApplicationUri from namespace identity so each instance in a
redundant pair has a unique server URI while sharing the same Galaxy
namespace. Exposes RedundancySupport, ServerUriArray, and dynamic
ServiceLevel through the standard OPC UA server object. ServiceLevel
is computed from role (Primary/Secondary) and runtime health (MXAccess
and DB connectivity). Adds CLI redundancy command, second deployed
service instance, and 31 new tests including paired-server integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-28 13:32:17 -04:00
parent a3c2d9b243
commit a55153d7d5
27 changed files with 1475 additions and 248 deletions

View File

@@ -39,5 +39,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
/// </summary>
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration();
/// <summary>
/// Gets or sets the redundancy settings that control how this server participates in a redundant pair.
/// </summary>
public RedundancyConfiguration Redundancy { get; set; } = new RedundancyConfiguration();
}
}

View File

@@ -104,6 +104,47 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
}
// Redundancy
if (config.OpcUa.ApplicationUri != null)
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
Log.Information("Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role, config.Redundancy.ServiceLevelBase);
if (config.Redundancy.ServerUris.Count > 0)
Log.Information("Redundancy.ServerUris=[{ServerUris}]", string.Join(", ", config.Redundancy.ServerUris));
if (config.Redundancy.Enabled)
{
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
{
Log.Error("OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
valid = false;
}
if (config.Redundancy.ServerUris.Count < 2)
{
Log.Warning("Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
}
if (config.OpcUa.ApplicationUri != null && !config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
{
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris", config.OpcUa.ApplicationUri);
}
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
if (mode == Opc.Ua.RedundancySupport.None)
{
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None", config.Redundancy.Mode);
}
}
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
{
Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255");
valid = false;
}
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
return valid;
}

View File

@@ -31,6 +31,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public string GalaxyName { get; set; } = "ZB";
/// <summary>
/// Gets or sets the explicit application URI for this server instance.
/// When <see langword="null"/>, defaults to <c>urn:{GalaxyName}:LmxOpcUa</c>.
/// Must be set to a unique value per instance when redundancy is enabled.
/// </summary>
public string? ApplicationUri { get; set; }
/// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary>

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Non-transparent redundancy settings that control how the server advertises itself
/// within a redundant pair and computes its dynamic ServiceLevel.
/// </summary>
public class RedundancyConfiguration
{
/// <summary>
/// Gets or sets whether redundancy is enabled. When <see langword="false"/> (default),
/// the server reports <c>RedundancySupport.None</c> and <c>ServiceLevel = 255</c>.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the redundancy mode. Valid values: <c>Warm</c>, <c>Hot</c>.
/// </summary>
public string Mode { get; set; } = "Warm";
/// <summary>
/// Gets or sets the role of this instance. Valid values: <c>Primary</c>, <c>Secondary</c>.
/// The primary advertises a higher ServiceLevel than the secondary when both are healthy.
/// </summary>
public string Role { get; set; } = "Primary";
/// <summary>
/// Gets or sets the ApplicationUri values for all servers in the redundant set.
/// Must include this instance's own <c>OpcUa.ApplicationUri</c>.
/// </summary>
public List<string> ServerUris { get; set; } = new List<string>();
/// <summary>
/// Gets or sets the base ServiceLevel when the server is fully healthy.
/// The secondary automatically receives <c>ServiceLevelBase - 50</c>.
/// Valid range: 1-255.
/// </summary>
public int ServiceLevelBase { get; set; } = 200;
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
using Opc.Ua;
@@ -11,7 +12,8 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Custom OPC UA server that creates the LmxNodeManager and handles user authentication. (OPC-001, OPC-012)
/// Custom OPC UA server that creates the LmxNodeManager, handles user authentication,
/// and exposes redundancy state through the standard server object. (OPC-001, OPC-012)
/// </summary>
public class LmxOpcUaServer : StandardServer
{
@@ -24,6 +26,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly bool _alarmTrackingEnabled;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly RedundancyConfiguration _redundancyConfig;
private readonly string? _applicationUri;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
private LmxNodeManager? _nodeManager;
/// <summary>
@@ -45,7 +50,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false,
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null)
AuthenticationConfiguration? authConfig = null, IUserAuthenticationProvider? authProvider = null,
RedundancyConfiguration? redundancyConfig = null, string? applicationUri = null)
{
_galaxyName = galaxyName;
_mxAccessClient = mxAccessClient;
@@ -54,6 +60,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_alarmTrackingEnabled = alarmTrackingEnabled;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
_applicationUri = applicationUri;
}
/// <inheritdoc />
@@ -72,6 +80,104 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
base.OnServerStarted(server);
server.SessionManager.ImpersonateUser += OnImpersonateUser;
ConfigureRedundancy(server);
}
private void ConfigureRedundancy(IServerInternal server)
{
var mode = RedundancyModeResolver.Resolve(_redundancyConfig.Mode, _redundancyConfig.Enabled);
try
{
// Set RedundancySupport via the diagnostics node manager
var redundancySupportNodeId = VariableIds.Server_ServerRedundancy_RedundancySupport;
var redundancySupportNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
redundancySupportNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (redundancySupportNode != null)
{
redundancySupportNode.Value = (int)mode;
redundancySupportNode.ClearChangeMasks(server.DefaultSystemContext, false);
Log.Information("Set RedundancySupport to {Mode}", mode);
}
// Set ServerUriArray for non-transparent redundancy
if (_redundancyConfig.Enabled && _redundancyConfig.ServerUris.Count > 0)
{
var serverUriArrayNodeId = VariableIds.Server_ServerRedundancy_ServerUriArray;
var serverUriArrayNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
serverUriArrayNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (serverUriArrayNode != null)
{
serverUriArrayNode.Value = _redundancyConfig.ServerUris.ToArray();
serverUriArrayNode.ClearChangeMasks(server.DefaultSystemContext, false);
Log.Information("Set ServerUriArray to [{Uris}]", string.Join(", ", _redundancyConfig.ServerUris));
}
else
{
Log.Warning("ServerUriArray node not found in address space — SDK may not expose it for RedundancySupport.None base type");
}
}
// Set initial ServiceLevel
var initialLevel = CalculateCurrentServiceLevel(true, true);
SetServiceLevelValue(server, initialLevel);
Log.Information("Initial ServiceLevel set to {ServiceLevel}", initialLevel);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to configure redundancy nodes — redundancy state may not be visible to clients");
}
}
/// <summary>
/// Updates the server's ServiceLevel based on current runtime health.
/// Called by the service layer when MXAccess or DB health changes.
/// </summary>
/// <param name="mxAccessConnected">Whether the MXAccess connection is healthy.</param>
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
try
{
if (ServerInternal != null)
{
SetServiceLevelValue(ServerInternal, level);
}
}
catch (Exception ex)
{
Log.Debug(ex, "Failed to update ServiceLevel node");
}
}
private byte CalculateCurrentServiceLevel(bool mxAccessConnected, bool dbConnected)
{
if (!_redundancyConfig.Enabled)
return 255; // SDK default when redundancy is not configured
var isPrimary = string.Equals(_redundancyConfig.Role, "Primary", StringComparison.OrdinalIgnoreCase);
var baseLevel = isPrimary
? _redundancyConfig.ServiceLevelBase
: Math.Max(0, _redundancyConfig.ServiceLevelBase - 50);
return _serviceLevelCalculator.Calculate(baseLevel, mxAccessConnected, dbConnected);
}
private static void SetServiceLevelValue(IServerInternal server, byte level)
{
var serviceLevelNodeId = VariableIds.Server_ServiceLevel;
var serviceLevelNode = server.DiagnosticsNodeManager?.FindPredefinedNode(
serviceLevelNodeId, typeof(BaseVariableState)) as BaseVariableState;
if (serviceLevelNode != null)
{
serviceLevelNode.Value = level;
serviceLevelNode.ClearChangeMasks(server.DefaultSystemContext, false);
}
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)

View File

@@ -25,6 +25,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly SecurityProfileConfiguration _securityConfig;
private readonly RedundancyConfiguration _redundancyConfig;
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
@@ -43,6 +44,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public bool IsRunning => _server != null;
/// <summary>
/// Updates the OPC UA ServiceLevel based on current runtime health.
/// </summary>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected) =>
_server?.UpdateServiceLevel(mxAccessConnected, dbConnected);
/// <summary>
/// Initializes a new host for the Galaxy-backed OPC UA server instance.
/// </summary>
@@ -54,7 +61,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
HistorianDataSource? historianDataSource = null,
AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null,
SecurityProfileConfiguration? securityConfig = null)
SecurityProfileConfiguration? securityConfig = null,
RedundancyConfiguration? redundancyConfig = null)
{
_config = config;
_mxAccessClient = mxAccessClient;
@@ -63,6 +71,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
}
/// <summary>
@@ -71,6 +80,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
public async Task StartAsync()
{
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
var applicationUri = _config.ApplicationUri ?? namespaceUri;
// Resolve configured security profiles
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
@@ -127,7 +137,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var appConfig = new ApplicationConfiguration
{
ApplicationName = _config.ServerName,
ApplicationUri = namespaceUri,
ApplicationUri = applicationUri,
ApplicationType = ApplicationType.Server,
ProductUri = namespaceUri,
ServerConfiguration = serverConfig,
@@ -174,11 +184,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
_config.AlarmTrackingEnabled, _authConfig, _authProvider);
_config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri);
await _application.Start(_server);
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (namespace={Namespace})",
_config.BindAddress, _config.Port, _config.EndpointPath, namespaceUri);
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
_config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
}
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)

View File

@@ -0,0 +1,41 @@
using System;
using Opc.Ua;
using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Maps a configured redundancy mode string to the OPC UA <see cref="RedundancySupport"/> enum.
/// </summary>
public static class RedundancyModeResolver
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(RedundancyModeResolver));
/// <summary>
/// Resolves the configured mode string to a <see cref="RedundancySupport"/> value.
/// Returns <see cref="RedundancySupport.None"/> when redundancy is disabled or the mode is unrecognized.
/// </summary>
/// <param name="mode">The mode string from configuration (e.g., "Warm", "Hot").</param>
/// <param name="enabled">Whether redundancy is enabled.</param>
/// <returns>The resolved redundancy support mode.</returns>
public static RedundancySupport Resolve(string mode, bool enabled)
{
if (!enabled)
return RedundancySupport.None;
var resolved = (mode ?? "").Trim().ToLowerInvariant() switch
{
"warm" => RedundancySupport.Warm,
"hot" => RedundancySupport.Hot,
_ => RedundancySupport.None
};
if (resolved == RedundancySupport.None)
{
Log.Warning("Unknown redundancy mode '{Mode}' — falling back to None. Supported modes: Warm, Hot", mode);
}
return resolved;
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Computes the OPC UA ServiceLevel byte from a baseline and runtime health inputs.
/// </summary>
public sealed class ServiceLevelCalculator
{
/// <summary>
/// Calculates the current ServiceLevel from a role-adjusted baseline and health state.
/// </summary>
/// <param name="baseLevel">The role-adjusted baseline (e.g., 200 for primary, 150 for secondary).</param>
/// <param name="mxAccessConnected">Whether the MXAccess runtime connection is healthy.</param>
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
/// <returns>A ServiceLevel byte between 0 and 255.</returns>
public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected)
{
if (!mxAccessConnected && !dbConnected)
return 0;
int level = baseLevel;
if (!mxAccessConnected)
level -= 100;
if (!dbConnected)
level -= 50;
return (byte)Math.Max(0, Math.Min(level, 255));
}
}
}

View File

@@ -59,6 +59,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
// Clear the default Profiles list before binding so JSON values replace rather than append
_config.Security.Profiles.Clear();
configuration.GetSection("Security").Bind(_config.Security);
configuration.GetSection("Redundancy").Bind(_config.Redundancy);
_mxProxy = new MxProxyAdapter();
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
@@ -164,7 +165,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
: (Domain.IUserAuthenticationProvider?)null;
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
_config.Authentication, authProvider, _config.Security);
_config.Authentication, authProvider, _config.Security, _config.Redundancy);
// Step 9-10: Query hierarchy, start server, build address space
DateTime? initialDeployTime = null;
@@ -222,6 +223,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_statusWebServer.Start();
}
// Wire ServiceLevel updates from MXAccess health changes
if (_config.Redundancy.Enabled)
{
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
}
// Step 14
Log.Information("LmxOpcUa service started successfully");
}
@@ -294,6 +301,14 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
}
}
private void OnMxAccessStateChangedForServiceLevel(object? sender, Domain.ConnectionStateChangedEventArgs e)
{
var mxConnected = e.CurrentState == Domain.ConnectionState.Connected;
var dbConnected = _galaxyStats?.DbConnected ?? false;
_serverHost?.UpdateServiceLevel(mxConnected, dbConnected);
Log.Debug("ServiceLevel updated: MxAccess={MxState}, DB={DbState}", e.CurrentState, dbConnected);
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);

View File

@@ -122,6 +122,28 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Sets the redundancy configuration for the test host.
/// </summary>
/// <param name="redundancy">The redundancy configuration to inject.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithRedundancy(RedundancyConfiguration redundancy)
{
_config.Redundancy = redundancy;
return this;
}
/// <summary>
/// Sets the application URI for the test host, distinct from the namespace URI.
/// </summary>
/// <param name="applicationUri">The unique application URI for this server instance.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithApplicationUri(string applicationUri)
{
_config.OpcUa.ApplicationUri = applicationUri;
return this;
}
/// <summary>
/// Sets the security profile configuration for the test host.
/// </summary>

View File

@@ -7,7 +7,8 @@
"GalaxyName": "ZB",
"MaxSessions": 100,
"SessionTimeoutMinutes": 30,
"AlarmTrackingEnabled": false
"AlarmTrackingEnabled": false,
"ApplicationUri": null
},
"MxAccess": {
"ClientName": "LmxOpcUa",
@@ -45,6 +46,13 @@
"PkiRootPath": null,
"CertificateSubject": null
},
"Redundancy": {
"Enabled": false,
"Mode": "Warm",
"Role": "Primary",
"ServerUris": [],
"ServiceLevelBase": 200
},
"Historian": {
"Enabled": false,
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",