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

@@ -81,6 +81,10 @@ dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # single test
The server supports configurable OPC UA transport security via the `Security` section in `appsettings.json`. Phase 1 profiles: `None` (default), `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Security profiles are resolved by `SecurityProfileResolver` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide. The server supports configurable OPC UA transport security via the `Security` section in `appsettings.json`. Phase 1 profiles: `None` (default), `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Security profiles are resolved by `SecurityProfileResolver` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide.
## Redundancy
The server supports non-transparent warm/hot redundancy via the `Redundancy` section in `appsettings.json`. Two instances share the same Galaxy DB and MXAccess runtime but have unique `ApplicationUri` values. Each exposes `RedundancySupport`, `ServerUriArray`, and a dynamic `ServiceLevel` based on role and runtime health. The primary advertises a higher ServiceLevel than the secondary. See `docs/Redundancy.md` for the full guide.
## Library Preferences ## Library Preferences
- **Logging**: Serilog with rolling daily file sink - **Logging**: Serilog with rolling daily file sink

View File

@@ -141,6 +141,7 @@ gr/ Galaxy repository docs, SQL queries, schema
| [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling | | [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling |
| [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands | | [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands |
| [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening | | [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening |
| [Redundancy](docs/Redundancy.md) | Non-transparent warm/hot redundancy, ServiceLevel, paired deployment |
## Related Documentation ## Related Documentation

View File

@@ -222,3 +222,29 @@ The command builds an `EventFilter` with select clauses for 12 fields from the O
When an `EventFieldList` notification arrives, the handler extracts these fields by index and prints a structured alarm event to the console showing the source name, condition name, active/acknowledged state, severity, message, retain flag, and suppressed/shelved status. When an `EventFieldList` notification arrives, the handler extracts these fields by index and prints a structured alarm event to the console showing the source name, condition name, active/acknowledged state, severity, message, retain flag, and suppressed/shelved status.
The `--refresh` flag calls `subscription.ConditionRefreshAsync()` after the subscription is created, which asks the server to re-emit retained condition events so the operator sees the current alarm state immediately rather than waiting for the next transition. The `--refresh` flag calls `subscription.ConditionRefreshAsync()` after the subscription is created, which asks the server to re-emit retained condition events so the operator sees the current alarm state immediately rather than waiting for the next transition.
### redundancy
Reads the OPC UA redundancy state from a server: `RedundancySupport`, `ServiceLevel`, `ServerUriArray`, and `ApplicationUri`.
```bash
dotnet run -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa
```
Example output:
```text
Redundancy Mode: Warm
Service Level: 200
Server URIs:
- urn:localhost:LmxOpcUa:instance1
- urn:localhost:LmxOpcUa:instance2
Application URI: urn:localhost:LmxOpcUa:instance1
```
| Flag | Description |
|------|-------------|
| `-u` | OPC UA server endpoint URL (required) |
| `-S` | Transport security mode (default: none) |
| `-U` | Username for authentication |
| `-P` | Password for authentication |

View File

@@ -55,6 +55,7 @@ Controls the OPC UA server endpoint and session limits. Defined in `OpcUaConfigu
| `MaxSessions` | `int` | `100` | Maximum simultaneous OPC UA sessions | | `MaxSessions` | `int` | `100` | Maximum simultaneous OPC UA sessions |
| `SessionTimeoutMinutes` | `int` | `30` | Idle session timeout in minutes | | `SessionTimeoutMinutes` | `int` | `30` | Idle session timeout in minutes |
| `AlarmTrackingEnabled` | `bool` | `false` | Enables `AlarmConditionState` nodes for alarm attributes | | `AlarmTrackingEnabled` | `bool` | `false` | Enables `AlarmConditionState` nodes for alarm attributes |
| `ApplicationUri` | `string?` | `null` | Explicit application URI for this server instance. Required when redundancy is enabled. Defaults to `urn:{GalaxyName}:LmxOpcUa` when null |
### MxAccess ### MxAccess
@@ -156,6 +157,30 @@ Example — production deployment with encrypted transport:
} }
``` ```
### Redundancy
Controls non-transparent OPC UA redundancy. Defined in `RedundancyConfiguration`. See [Redundancy Guide](Redundancy.md) for detailed usage.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Enabled` | `bool` | `false` | Enables redundancy mode and ServiceLevel computation |
| `Mode` | `string` | `"Warm"` | Redundancy mode: `Warm` or `Hot` |
| `Role` | `string` | `"Primary"` | Instance role: `Primary` (higher ServiceLevel) or `Secondary` |
| `ServerUris` | `List<string>` | `[]` | ApplicationUri values for all servers in the redundant set |
| `ServiceLevelBase` | `int` | `200` | Base ServiceLevel when healthy (1-255). Secondary receives base - 50 |
Example — two-instance redundant pair (Primary):
```json
"Redundancy": {
"Enabled": true,
"Mode": "Warm",
"Role": "Primary",
"ServerUris": ["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"],
"ServiceLevelBase": 200
}
```
## Feature Flags ## Feature Flags
Three boolean properties act as feature flags that control optional subsystems: Three boolean properties act as feature flags that control optional subsystems:
@@ -176,6 +201,10 @@ Three boolean properties act as feature flags that control optional subsystems:
- Unknown security profile names are logged as warnings - Unknown security profile names are logged as warnings
- `AutoAcceptClientCertificates = true` emits a warning - `AutoAcceptClientCertificates = true` emits a warning
- Only-`None` profile configuration emits a warning - Only-`None` profile configuration emits a warning
- `OpcUa.ApplicationUri` must be set when `Redundancy.Enabled = true`
- `Redundancy.ServiceLevelBase` must be between 1 and 255
- `Redundancy.ServerUris` should contain at least 2 entries when enabled
- Local `ApplicationUri` should appear in `Redundancy.ServerUris`
If validation fails, the service throws `InvalidOperationException` and does not start. If validation fails, the service throws `InvalidOperationException` and does not start.
@@ -206,7 +235,8 @@ Integration tests use this constructor to inject substitute implementations of `
"GalaxyName": "ZB", "GalaxyName": "ZB",
"MaxSessions": 100, "MaxSessions": 100,
"SessionTimeoutMinutes": 30, "SessionTimeoutMinutes": 30,
"AlarmTrackingEnabled": false "AlarmTrackingEnabled": false,
"ApplicationUri": null
}, },
"MxAccess": { "MxAccess": {
"ClientName": "LmxOpcUa", "ClientName": "LmxOpcUa",
@@ -249,6 +279,13 @@ Integration tests use this constructor to inject substitute implementations of `
"MinimumCertificateKeySize": 2048, "MinimumCertificateKeySize": 2048,
"PkiRootPath": null, "PkiRootPath": null,
"CertificateSubject": null "CertificateSubject": null
},
"Redundancy": {
"Enabled": false,
"Mode": "Warm",
"Role": "Primary",
"ServerUris": [],
"ServiceLevelBase": 200
} }
} }
``` ```

View File

@@ -19,7 +19,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable
The resulting endpoint URL is `opc.tcp://{BindAddress}:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`. The resulting endpoint URL is `opc.tcp://{BindAddress}:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and serves as both the `ApplicationUri` and `ProductUri`. The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and is used as the `ProductUri`. The `ApplicationUri` can be set independently via `OpcUa.ApplicationUri` to support redundant deployments where each instance needs a unique identity. When `ApplicationUri` is null, it defaults to the namespace URI.
## Programmatic ApplicationConfiguration ## Programmatic ApplicationConfiguration
@@ -48,6 +48,16 @@ Supported Phase 1 profiles:
For production deployments, configure `["Basic256Sha256-SignAndEncrypt"]` or `["None", "Basic256Sha256-SignAndEncrypt"]` and set `AutoAcceptClientCertificates` to `false`. See the [Security Guide](security.md) for hardening details. For production deployments, configure `["Basic256Sha256-SignAndEncrypt"]` or `["None", "Basic256Sha256-SignAndEncrypt"]` and set `AutoAcceptClientCertificates` to `false`. See the [Security Guide](security.md) for hardening details.
## Redundancy
When `Redundancy.Enabled = true`, `LmxOpcUaServer` exposes the standard OPC UA redundancy nodes on startup:
- `Server/ServerRedundancy/RedundancySupport` — set to `Warm` or `Hot` based on configuration
- `Server/ServerRedundancy/ServerUriArray` — populated with the configured `ServerUris`
- `Server/ServiceLevel` — computed dynamically from role and runtime health
The `ServiceLevel` is updated whenever MXAccess connection state changes or Galaxy DB health changes. See [Redundancy Guide](Redundancy.md) for full details.
### User token policies ### User token policies
`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`: `UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`:

175
docs/Redundancy.md Normal file
View File

@@ -0,0 +1,175 @@
# Redundancy
## Overview
LmxOpcUa supports OPC UA **non-transparent redundancy** in Warm or Hot mode. In a non-transparent redundancy deployment, two independent server instances run side by side. Both connect to the same Galaxy repository database and the same MXAccess runtime, but each maintains its own OPC UA sessions and subscriptions. Clients discover the redundant set through the `ServerUriArray` exposed in each server's address space and are responsible for managing failover between the two endpoints.
When redundancy is disabled (the default), the server reports `RedundancySupport.None` and a fixed `ServiceLevel` of 255.
## Namespace vs Application Identity
Both servers in the redundant set share the same **namespace URI** so that clients see identical node IDs regardless of which instance they are connected to. The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` (e.g., `urn:ZB:LmxOpcUa`).
The **ApplicationUri**, on the other hand, must be unique per instance. This is how the OPC UA stack and clients distinguish one server from the other within the redundant set. Each instance sets its own ApplicationUri via the `OpcUa.ApplicationUri` configuration property (e.g., `urn:localhost:LmxOpcUa:instance1` and `urn:localhost:LmxOpcUa:instance2`).
When redundancy is disabled, `ApplicationUri` defaults to `urn:{GalaxyName}:LmxOpcUa` if left null.
## Configuration
### Redundancy Section
| Property | Type | Default | Description |
|---|---|---|---|
| `Enabled` | bool | `false` | Enables non-transparent redundancy. When false, the server reports `RedundancySupport.None` and `ServiceLevel = 255`. |
| `Mode` | string | `"Warm"` | The redundancy mode advertised to clients. Valid values: `Warm`, `Hot`. |
| `Role` | string | `"Primary"` | This instance's role in the redundant pair. Valid values: `Primary`, `Secondary`. The Primary advertises a higher ServiceLevel than the Secondary when both are healthy. |
| `ServerUris` | string[] | `[]` | The ApplicationUri values of all servers in the redundant set. Must include this instance's own `OpcUa.ApplicationUri`. Should contain at least 2 entries. |
| `ServiceLevelBase` | int | `200` | The base ServiceLevel when the server is fully healthy. Valid range: 1-255. The Secondary automatically receives `ServiceLevelBase - 50`. |
### OpcUa.ApplicationUri
| Property | Type | Default | Description |
|---|---|---|---|
| `ApplicationUri` | string | `null` | Explicit application URI for this server instance. When null, defaults to `urn:{GalaxyName}:LmxOpcUa`. **Required when redundancy is enabled** -- each instance needs a unique identity. |
## ServiceLevel Computation
ServiceLevel is a standard OPC UA diagnostic value (0-255) that indicates server health. Clients in a redundant deployment should prefer the server advertising the highest ServiceLevel.
**Baseline values:**
| Role | Baseline |
|---|---|
| Primary | `ServiceLevelBase` (default 200) |
| Secondary | `ServiceLevelBase - 50` (default 150) |
**Penalties applied to the baseline:**
| Condition | Penalty |
|---|---|
| MXAccess disconnected | -100 |
| Galaxy DB unreachable | -50 |
| Both MXAccess and DB down | ServiceLevel forced to 0 |
The final value is clamped to the range 0-255.
**Examples (with default ServiceLevelBase = 200):**
| Scenario | Primary | Secondary |
|---|---|---|
| Both healthy | 200 | 150 |
| MXAccess down | 100 | 50 |
| DB down | 150 | 100 |
| Both down | 0 | 0 |
## Two-Instance Deployment
When deploying a redundant pair, the following configuration properties must differ between the two instances. All other settings (GalaxyName, ConnectionString, etc.) are shared.
| Property | Instance 1 (Primary) | Instance 2 (Secondary) |
|---|---|---|
| `OpcUa.Port` | 4840 | 4841 |
| `OpcUa.ServerName` | `LmxOpcUa-1` | `LmxOpcUa-2` |
| `OpcUa.ApplicationUri` | `urn:localhost:LmxOpcUa:instance1` | `urn:localhost:LmxOpcUa:instance2` |
| `Dashboard.Port` | 8081 | 8082 |
| `MxAccess.ClientName` | `LmxOpcUa-1` | `LmxOpcUa-2` |
| `Redundancy.Role` | `Primary` | `Secondary` |
### Instance 1 -- Primary (appsettings.json)
```json
{
"OpcUa": {
"Port": 4840,
"ServerName": "LmxOpcUa-1",
"GalaxyName": "ZB",
"ApplicationUri": "urn:localhost:LmxOpcUa:instance1"
},
"MxAccess": {
"ClientName": "LmxOpcUa-1"
},
"Dashboard": {
"Port": 8081
},
"Redundancy": {
"Enabled": true,
"Mode": "Warm",
"Role": "Primary",
"ServerUris": [
"urn:localhost:LmxOpcUa:instance1",
"urn:localhost:LmxOpcUa:instance2"
],
"ServiceLevelBase": 200
}
}
```
### Instance 2 -- Secondary (appsettings.json)
```json
{
"OpcUa": {
"Port": 4841,
"ServerName": "LmxOpcUa-2",
"GalaxyName": "ZB",
"ApplicationUri": "urn:localhost:LmxOpcUa:instance2"
},
"MxAccess": {
"ClientName": "LmxOpcUa-2"
},
"Dashboard": {
"Port": 8082
},
"Redundancy": {
"Enabled": true,
"Mode": "Warm",
"Role": "Secondary",
"ServerUris": [
"urn:localhost:LmxOpcUa:instance1",
"urn:localhost:LmxOpcUa:instance2"
],
"ServiceLevelBase": 200
}
}
```
## CLI `redundancy` Command
The CLI tool at `tools/opcuacli-dotnet/` includes a `redundancy` command that reads the redundancy state from a running server.
```bash
dotnet run -- redundancy -u opc.tcp://localhost:4840/LmxOpcUa
dotnet run -- redundancy -u opc.tcp://localhost:4841/LmxOpcUa
```
The command reads the following standard OPC UA nodes and displays their values:
- **Redundancy Mode** -- from `Server_ServerRedundancy_RedundancySupport` (None, Warm, or Hot)
- **Service Level** -- from `Server_ServiceLevel` (0-255)
- **Server URIs** -- from `Server_ServerRedundancy_ServerUriArray` (list of ApplicationUri values in the redundant set)
- **Application URI** -- from `Server_ServerArray` (this instance's ApplicationUri)
Example output for a healthy Primary:
```
Redundancy Mode: Warm
Service Level: 200
Server URIs:
- urn:localhost:LmxOpcUa:instance1
- urn:localhost:LmxOpcUa:instance2
Application URI: urn:localhost:LmxOpcUa:instance1
```
The command also supports `--username`/`--password` and `--security` options for authenticated or encrypted connections.
## Troubleshooting
**Mismatched ServerUris between instances** -- Both instances must list the exact same set of ApplicationUri values in `Redundancy.ServerUris`. If they differ, clients may not discover the full redundant set. Check the startup log for the `Redundancy.ServerUris` line on each instance.
**ServiceLevel stuck at 255** -- This indicates redundancy is not enabled. When `Redundancy.Enabled` is false (the default), the server always reports `ServiceLevel = 255` and `RedundancySupport.None`. Verify that `Redundancy.Enabled` is set to `true` in the configuration and that the configuration section is correctly bound.
**ApplicationUri not set** -- The configuration validator rejects startup when redundancy is enabled but `OpcUa.ApplicationUri` is null or empty. Each instance must have a unique ApplicationUri. Check the error log for: `OpcUa.ApplicationUri must be set when redundancy is enabled`.
**Both servers report the same ServiceLevel** -- Verify that one instance has `Redundancy.Role` set to `Primary` and the other to `Secondary`. Both set to `Primary` (or both to `Secondary`) will produce identical baseline values, preventing clients from distinguishing the preferred server.
**ServerUriArray not readable** -- When `RedundancySupport` is `None` (redundancy disabled), the OPC UA SDK may not expose the `ServerUriArray` node or it may return an empty value. The CLI `redundancy` command handles this gracefully by catching the read error. Enable redundancy to populate this array.

View File

@@ -132,6 +132,25 @@ Log files are written relative to the executable directory (see Working Director
`Log.CloseAndFlush()` is called in the `finally` block of `Program.Main()` to ensure all buffered log entries are written before process exit. `Log.CloseAndFlush()` is called in the `finally` block of `Program.Main()` to ensure all buffered log entries are written before process exit.
## Multi-Instance Deployment
The service supports running multiple instances for redundancy. Each instance requires:
- A unique Windows service name (e.g., `LmxOpcUa`, `LmxOpcUa2`)
- A unique OPC UA port and dashboard port
- A unique `OpcUa.ApplicationUri` and `OpcUa.ServerName`
- A unique `MxAccess.ClientName`
- Matching `Redundancy.ServerUris` arrays on all instances
Install additional instances using TopShelf's `-servicename` flag:
```bash
cd C:\publish\lmxopcua\instance2
ZB.MOM.WW.LmxOpcUa.Host.exe install -servicename "LmxOpcUa2" -displayname "LMX OPC UA Server (Instance 2)"
```
See [Redundancy Guide](Redundancy.md) for full deployment details.
## Platform Target ## Platform Target
The service must be compiled and run as x86 (32-bit). The MXAccess COM toolkit DLLs in `Program Files (x86)\ArchestrA\Framework\bin` are 32-bit only. Running the service as x64 or AnyCPU (64-bit preferred) causes COM interop failures when creating the `LMXProxyServer` object on the STA thread. The service must be compiled and run as x86 (32-bit). The MXAccess COM toolkit DLLs in `Program Files (x86)\ArchestrA\Framework\bin` are 32-bit only. Running the service as x64 or AnyCPU (64-bit preferred) causes COM interop failures when creating the `LMXProxyServer` object on the STA thread.

View File

@@ -2,75 +2,117 @@
## Summary ## Summary
Add configurable non-transparent warm/hot redundancy to the LmxOpcUa server so that two instances sharing the same Galaxy repository can operate as a redundant pair. Each instance advertises itself and its partner through the OPC UA `ServerRedundancy` node, publishes a dynamic `ServiceLevel` reflecting runtime health, and allows clients to discover the redundant set and fail over between instances. The CLI tool gains a `redundancy` command for inspecting the redundant server set. Add configurable non-transparent warm/hot redundancy to the LmxOpcUa server so that two instances sharing the same Galaxy repository can operate as a redundant pair. Each instance should advertise the redundant set through the standard OPC UA redundancy nodes, publish a dynamic `ServiceLevel` based on runtime health, and allow clients to discover and fail over between the instances. The CLI tool should gain a `redundancy` command for inspecting the redundant server set.
This plan covers server-side redundancy exposure, client-side discovery, a second deployed service instance, documentation, and tests. It does **not** implement automatic server-side failover or subscription transfer — those are client responsibilities per the OPC UA specification. This review tightens the original draft in a few important ways:
- It separates **namespace identity** from **application identity**. The current host uses `urn:{GalaxyName}:LmxOpcUa` as both the namespace URI and `ApplicationUri`; that must change for redundancy because each server in the pair needs a unique server URI.
- It avoids hand-wavy "write the redundancy nodes directly" language and instead targets the OPC UA SDK's built-in `ServerObjectState` / `ServerRedundancyState` model.
- It removes a few inaccurate hardcoded assumptions, including the `ServerUriArray` node id and the deployment port examples.
- It fixes execution order so test-builder and helper changes happen before integration coverage depends on them.
This plan still covers server-side redundancy exposure, client-side discovery, a second deployed service instance, documentation, and tests. It does **not** implement automatic server-side failover or subscription transfer; those remain client responsibilities per the OPC UA specification.
--- ---
## Background: OPC UA Redundancy Model ## Background: OPC UA Redundancy Model
OPC UA defines redundancy through three address-space nodes under `Server/ServerRedundancy`: OPC UA exposes redundancy through standard nodes under `Server/ServerRedundancy` plus the `Server/ServiceLevel` property:
| Node | Type | Purpose | | Node | Type | Purpose |
|---|---|---| |---|---|---|
| `RedundancySupport` | `RedundancySupport` enum | Declares the redundancy mode: `None`, `Cold`, `Warm`, `Hot`, `Transparent`, `HotAndMirrored` | | `RedundancySupport` | `RedundancySupport` enum | Declares the redundancy mode: `None`, `Cold`, `Warm`, `Hot`, `Transparent`, `HotAndMirrored` |
| `ServerUriArray` | `String[]` | Lists the `ApplicationUri` values of all servers in the redundant set (non-transparent modes) | | `ServerUriArray` | `String[]` | Lists the `ApplicationUri` values of all servers in the redundant set for non-transparent redundancy |
| `ServiceLevel` | `Byte` (0255) | Indicates current operational quality; clients prefer the server with the highest value | | `ServiceLevel` | `Byte` (0-255) | Indicates current operational quality; clients prefer the server with the highest value |
### Non-Transparent Redundancy (our target) ### Non-Transparent Redundancy (our target)
In non-transparent redundancy (`Warm` or `Hot`), both servers run independently with their own sessions and subscriptions. Clients discover the redundant set by reading `ServerUriArray`, monitor `ServiceLevel` on each server, and manage their own failover. This model fits our architecture where each instance connects to the same Galaxy repository and MXAccess runtime independently. In non-transparent redundancy (`Warm` or `Hot`), both servers run independently with their own sessions and subscriptions. Clients discover the redundant set by reading `ServerUriArray`, monitor `ServiceLevel` on each server, and manage their own failover. This fits the current architecture, where each instance independently connects to the same Galaxy repository and MXAccess runtime.
### ServiceLevel Semantics ### ServiceLevel semantics
| Range | Meaning | | Range | Meaning |
|---|---| |---|---|
| 0 | Server is not operational | | 0 | Server is not operational |
| 199 | Degraded (e.g., MXAccess disconnected, DB unreachable) | | 1-99 | Degraded |
| 100199 | Healthy secondary | | 100-199 | Healthy secondary |
| 200255 | Healthy primary (preferred) | | 200-255 | Healthy primary |
The primary server should advertise a higher `ServiceLevel` than the secondary so clients prefer it when both are healthy. The primary should advertise a higher `ServiceLevel` than the secondary so clients prefer it when both are healthy.
--- ---
## Current State ## Current State
- `LmxOpcUaServer` extends `StandardServer` but does not override any redundancy-related methods - `LmxOpcUaServer` extends `StandardServer` but does not expose redundancy state
- `ServerRedundancy/RedundancySupport` defaults to `None` (SDK default) - `ServerRedundancy/RedundancySupport` remains the SDK default (`None`)
- `ServiceLevel` defaults to `255` (SDK default — "fully operational") - `Server/ServiceLevel` remains the SDK default (`255`)
- No configuration for redundant partner URIs or role designation - No configuration exists for redundancy mode, role, or redundant partner URIs
- Single deployed instance at `C:\publish\lmxopcua\instance1` on port 4840 - `OpcUaServerHost` currently sets `ApplicationUri = urn:{GalaxyName}:LmxOpcUa`
- No CLI support for reading redundancy information - `LmxNodeManager` uses the same `urn:{GalaxyName}:LmxOpcUa` as the published namespace URI
- A single deployed instance is documented in [service_info.md](C:\Users\dohertj2\Desktop\lmxopcua\service_info.md)
- No CLI command exists for reading redundancy information
## Key gap to fix first
For redundancy, each server in the set must advertise a unique `ApplicationUri`, and `ServerUriArray` must contain those unique values. The current implementation cannot do that because it reuses the namespace URI as the server `ApplicationUri`. Phase 1 therefore needs an application-identity change before the redundancy nodes can be correct.
--- ---
## Scope ## Scope
### In Scope (Phase 1) ### In scope (Phase 1)
1. **Redundancy configuration model** — role, partner URIs, ServiceLevel weights 1. Add explicit application-identity configuration so each instance can have a unique `ApplicationUri`
2. **Server redundancy node exposure**`RedundancySupport`, `ServerUriArray`, dynamic `ServiceLevel` 2. Add redundancy configuration for mode, role, and server URI membership
3. **ServiceLevel computation** — based on runtime health (MXAccess state, DB connectivity, role) 3. Expose `RedundancySupport`, `ServerUriArray`, and dynamic `ServiceLevel`
4. **CLI redundancy command** — read `RedundancySupport`, `ServerUriArray`, `ServiceLevel` from a server 4. Compute `ServiceLevel` from runtime health and preferred role
5. **Second service instance** — deployed at `C:\publish\lmxopcua\instance2` with non-overlapping ports 5. Add a CLI `redundancy` command
6. **Documentation** — new `docs/Redundancy.md` component doc, updates to existing docs 6. Document two-instance deployment
7. **Unit tests** — config, ServiceLevel computation, resolver tests 7. Add unit and integration coverage
8. **Integration tests** — two-server redundancy E2E test in the integration test project
### Deferred ### Deferred
- Automatic subscription transfer (client-side responsibility) - Automatic subscription transfer
- Server-initiated failover (Galaxy `redundancy` table / engine flags) - Server-initiated failover
- Transparent redundancy mode - Transparent redundancy mode
- Health-check HTTP endpoint for load balancers - Load-balancer-specific HTTP health endpoints
- Mirrored data/session state
--- ---
## Configuration Design ## Configuration Design
### New `Redundancy` section in `appsettings.json` ### 1. Add explicit `OpcUa.ApplicationUri`
**File:** `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs`
Add:
```csharp
public string? ApplicationUri { get; set; }
```
Rules:
- `ApplicationUri = null` preserves the current behavior for non-redundant deployments
- when `Redundancy.Enabled = true`, `ApplicationUri` must be explicitly set and unique per instance
- `LmxNodeManager` should continue using `urn:{GalaxyName}:LmxOpcUa` as the namespace URI so both redundant servers expose the same namespace
- `Redundancy.ServerUris` must contain the exact `ApplicationUri` values for all servers in the redundant set
Example:
```json
{
"OpcUa": {
"ServerName": "LmxOpcUa",
"GalaxyName": "ZB",
"ApplicationUri": "urn:localhost:LmxOpcUa:instance1"
}
}
```
### 2. New `Redundancy` section in `appsettings.json`
```json ```json
{ {
@@ -84,7 +126,7 @@ The primary server should advertise a higher `ServiceLevel` than the secondary s
} }
``` ```
### Configuration model ### 3. Configuration model
**File:** `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs` (new) **File:** `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/RedundancyConfiguration.cs` (new)
@@ -99,13 +141,17 @@ public class RedundancyConfiguration
} }
``` ```
### Configuration rules ### 4. Configuration rules
- `Enabled` defaults to `false` for backward compatibility. When `false`, `RedundancySupport = None` and `ServiceLevel = 255` (SDK defaults). - `Enabled` defaults to `false`
- `Mode` must be `Warm` or `Hot` (Phase 1). Maps to `RedundancySupport.Warm` or `RedundancySupport.Hot`. - `Mode` supports `Warm` and `Hot` in Phase 1
- `Role` must be `Primary` or `Secondary`. Controls the base `ServiceLevel` (Primary gets `ServiceLevelBase`, Secondary gets `ServiceLevelBase - 50`). - `Role` supports `Primary` and `Secondary`
- `ServerUris` lists the `ApplicationUri` values for **all** servers in the redundant set, including the local server. The OPC UA spec requires this to contain the full set. These are namespace URIs like `urn:ZB:LmxOpcUa`, not endpoint URLs. - `ServerUris` must contain the local `OpcUa.ApplicationUri` when redundancy is enabled
- `ServiceLevelBase` is the starting ServiceLevel when the server is fully healthy. Degraded conditions subtract from this value. - `ServerUris` should contain at least two unique entries when redundancy is enabled
- `ServiceLevelBase` should be in the range `1-255`
- Effective baseline:
- Primary: `ServiceLevelBase`
- Secondary: `max(0, ServiceLevelBase - 50)`
### App root updates ### App root updates
@@ -117,27 +163,48 @@ public class RedundancyConfiguration
## Implementation Steps ## Implementation Steps
### Step 1: Add RedundancyConfiguration model and bind it ### Step 1: Separate application identity from namespace identity
**Files:** **Files:**
- `src/.../Configuration/OpcUaConfiguration.cs`
- `src/.../OpcUa/OpcUaServerHost.cs`
- `docs/OpcUaServer.md`
- `tests/.../Configuration/ConfigurationLoadingTests.cs`
Changes:
1. Add optional `OpcUa.ApplicationUri`
2. Keep `urn:{GalaxyName}:LmxOpcUa` as the namespace URI used by `LmxNodeManager`
3. Set `ApplicationConfiguration.ApplicationUri` from `OpcUa.ApplicationUri` when supplied
4. Keep `ApplicationUri` and namespace URI distinct in docs and tests
This step is required before redundancy can be correct.
### Step 2: Add `RedundancyConfiguration` and bind it
**Files:**
- `src/.../Configuration/RedundancyConfiguration.cs` (new) - `src/.../Configuration/RedundancyConfiguration.cs` (new)
- `src/.../Configuration/AppConfiguration.cs` - `src/.../Configuration/AppConfiguration.cs`
- `src/.../OpcUaService.cs` - `src/.../OpcUaService.cs`
Changes: Changes:
1. Create `RedundancyConfiguration` class with properties above
2. Add `Redundancy` property to `AppConfiguration` 1. Create `RedundancyConfiguration`
2. Add `Redundancy` to `AppConfiguration`
3. Bind `configuration.GetSection("Redundancy").Bind(_config.Redundancy);` 3. Bind `configuration.GetSection("Redundancy").Bind(_config.Redundancy);`
4. Pass `_config.Redundancy` through to `OpcUaServerHost` and `LmxOpcUaServer` 4. Pass `_config.Redundancy` through to `OpcUaServerHost` and `LmxOpcUaServer`
### Step 2: Add RedundancyModeResolver ### Step 3: Add `RedundancyModeResolver`
**File:** `src/.../OpcUa/RedundancyModeResolver.cs` (new) **File:** `src/.../OpcUa/RedundancyModeResolver.cs` (new)
Responsibilities: Responsibilities:
- Map `Mode` string to `RedundancySupport` enum value
- Validate against supported Phase 1 modes (`Warm`, `Hot`) - map `Mode` to `RedundancySupport`
- Fall back to `None` with warning for unknown modes - validate supported Phase 1 modes
- fall back safely when disabled or invalid
```csharp ```csharp
public static class RedundancyModeResolver public static class RedundancyModeResolver
@@ -146,169 +213,199 @@ public static class RedundancyModeResolver
} }
``` ```
### Step 3: Add ServiceLevelCalculator ### Step 4: Add `ServiceLevelCalculator`
**File:** `src/.../OpcUa/ServiceLevelCalculator.cs` (new) **File:** `src/.../OpcUa/ServiceLevelCalculator.cs` (new)
Computes the dynamic `ServiceLevel` byte from runtime health: Purpose:
- compute the current `ServiceLevel` from a baseline plus health inputs
Suggested signature:
```csharp ```csharp
public class ServiceLevelCalculator public sealed class ServiceLevelCalculator
{ {
public byte Calculate(int baseLine, bool mxAccessConnected, bool dbConnected, bool isPrimary); public byte Calculate(int baseLevel, bool mxAccessConnected, bool dbConnected);
} }
``` ```
Logic: Suggested logic:
- Start with `baseLine` (from config, e.g., 200 for Primary, 150 for Secondary)
- Subtract 100 if MXAccess is disconnected
- Subtract 50 if Galaxy DB is unreachable
- Clamp to 0255
- Return 0 if both MXAccess and DB are down
### Step 4: Extend ConfigurationValidator for redundancy - start with the role-adjusted baseline supplied by the caller
- subtract 100 if MXAccess is disconnected
- subtract 50 if the Galaxy DB is unreachable
- return `0` if both are down
- clamp to `0-255`
### Step 5: Extend `ConfigurationValidator`
**File:** `src/.../Configuration/ConfigurationValidator.cs` **File:** `src/.../Configuration/ConfigurationValidator.cs`
Add validation/logging for: Add validation/logging for:
- `Redundancy.Enabled`, `Mode`, `Role`
- `ServerUris` should not be empty when `Enabled = true`
- `ServiceLevelBase` should be 1255
- Warning when `Enabled = true` but `ServerUris` has fewer than 2 entries
- Log effective redundancy configuration at startup
### Step 5: Update LmxOpcUaServer to expose redundancy state - `OpcUa.ApplicationUri`
- `Redundancy.Enabled`, `Mode`, `Role`
- `ServerUris` membership and uniqueness
- `ServiceLevelBase`
- local `OpcUa.ApplicationUri` must appear in `Redundancy.ServerUris` when enabled
- warning when fewer than 2 unique server URIs are configured
### Step 6: Expose redundancy through the standard OPC UA server object
**File:** `src/.../OpcUa/LmxOpcUaServer.cs` **File:** `src/.../OpcUa/LmxOpcUaServer.cs`
Changes: Changes:
1. Accept `RedundancyConfiguration` in the constructor
2. Override `OnServerStarted` to write redundancy nodes:
- Set `Server/ServerRedundancy/RedundancySupport` to the resolved mode
- Set `Server/ServerRedundancy/ServerUriArray` to the configured URIs
3. Override `SetServerState` or use a timer to update `Server/ServiceLevel` periodically based on `ServiceLevelCalculator`
4. Expose a method `UpdateServiceLevel(bool mxConnected, bool dbConnected)` that the service layer can call when health state changes
### Step 6: Update OpcUaServerHost to pass redundancy config 1. Accept `RedundancyConfiguration` and local `ApplicationUri`
2. On startup, locate the built-in `ServerObjectState`
3. Configure `ServerObjectState.ServiceLevel`
4. Configure the server redundancy object using the SDK's standard server-state types instead of writing guessed node ids directly
5. If the default `ServerRedundancyState` does not expose `ServerUriArray`, replace or upgrade it with the appropriate non-transparent redundancy state type from the SDK before populating values
6. Expose an internal method such as `UpdateServiceLevel(bool mxConnected, bool dbConnected)` for service-layer health updates
Important: the implementation should use SDK types/constants (`ServerObjectState`, `ServerRedundancyState`, `NonTransparentRedundancyState`, `VariableIds.*`) rather than hand-maintained numeric literals.
### Step 7: Update `OpcUaServerHost`
**File:** `src/.../OpcUa/OpcUaServerHost.cs` **File:** `src/.../OpcUa/OpcUaServerHost.cs`
Changes: Changes:
1. Accept `RedundancyConfiguration` in the constructor
2. Pass it through to `LmxOpcUaServer`
3. Log active redundancy mode at startup
### Step 7: Wire ServiceLevel updates in OpcUaService 1. Accept `RedundancyConfiguration`
2. Pass redundancy config and resolved local `ApplicationUri` into `LmxOpcUaServer`
3. Log redundancy mode/role/server URIs at startup
### Step 8: Wire health updates in `OpcUaService`
**File:** `src/.../OpcUaService.cs` **File:** `src/.../OpcUaService.cs`
Changes: Changes:
1. Bind redundancy config section
2. Pass redundancy config to `OpcUaServerHost`
3. Subscribe to `MxAccessClient.ConnectionStateChanged` to trigger `ServiceLevel` updates
4. After Galaxy DB health checks, trigger `ServiceLevel` updates
5. Use a periodic timer (e.g., every 5 seconds) to refresh `ServiceLevel` based on current component health
### Step 8: Update appsettings.json 1. Bind and pass redundancy config
2. After startup, initialize the starting `ServiceLevel`
3. Subscribe to `IMxAccessClient.ConnectionStateChanged`
4. Update DB health whenever startup repository checks, change-detection work, or rebuild attempts succeed/fail
5. Prefer event-driven updates; add a lightweight periodic refresh only if necessary
Avoid introducing a second large standalone polling loop when existing connection and repository activity already gives most of the needed health signals.
### Step 9: Update test builders and helpers before integration coverage
**Files:**
- `src/.../OpcUaServiceBuilder.cs`
- `tests/.../Helpers/OpcUaServerFixture.cs`
- `tests/.../Helpers/OpcUaTestClient.cs`
Changes:
- add `WithRedundancy(...)`
- add `WithApplicationUri(...)` or allow full `OpcUaConfiguration` override
- ensure two in-process redundancy tests can run with distinct `ServerName`, `ApplicationUri`, and certificate identity
- when needed, use separate PKI roots in tests so paired fixtures do not collide on certificate state
### Step 10: Update `appsettings.json`
**File:** `src/.../appsettings.json` **File:** `src/.../appsettings.json`
Add the `Redundancy` section with backward-compatible defaults (`Enabled: false`). Add:
### Step 9: Update OpcUaServiceBuilder for test injection - `OpcUa.ApplicationUri` example/commentary in docs
- `Redundancy` section with `Enabled = false` defaults
**File:** `src/.../OpcUaServiceBuilder.cs` ### Step 11: Add CLI `redundancy` command
Add `WithRedundancy(RedundancyConfiguration)` builder method so tests can inject redundancy configuration.
### Step 10: Add CLI `redundancy` command
**Files:** **Files:**
- `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` (new) - `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` (new)
- `tools/opcuacli-dotnet/README.md`
- `docs/CliTool.md`
Command: `redundancy` Command: `redundancy`
Reads from the target server: Read:
- `Server/ServerRedundancy/RedundancySupport` (i=11314)
- `Server/ServiceLevel` (i=2267)
- `Server/ServerRedundancy/ServerUriArray` (i=11492, if non-transparent redundancy)
Output format: - `VariableIds.Server_ServerRedundancy_RedundancySupport`
``` - `VariableIds.Server_ServiceLevel`
- `VariableIds.Server_ServerRedundancy_ServerUriArray`
Output example:
```text
Redundancy Mode: Warm Redundancy Mode: Warm
Service Level: 200 Service Level: 200
Server URIs: Server URIs:
- urn:ZB:LmxOpcUa - urn:localhost:LmxOpcUa:instance1
- urn:ZB:LmxOpcUa2 - urn:localhost:LmxOpcUa:instance2
``` ```
Options: `--url`, `--username`, `--password`, `--security` (same shared options as other commands). Use SDK constants instead of hardcoded numeric ids in the command implementation.
### Step 11: Deploy second service instance ### Step 12: Deploy the second service instance
**Deployment target:** `C:\publish\lmxopcua\instance2` **Deployment target:** `C:\publish\lmxopcua\instance2`
Configuration differences from instance1: Suggested configuration differences:
| Setting | instance1 | instance2 | | Setting | instance1 | instance2 |
|---|---|---| |---|---|---|
| `OpcUa.Port` | `4840` | `4841` | | `OpcUa.Port` | `4840` | `4841` |
| `Dashboard.Port` | `8081` | `8082` |
| `OpcUa.ServerName` | `LmxOpcUa` | `LmxOpcUa2` | | `OpcUa.ServerName` | `LmxOpcUa` | `LmxOpcUa2` |
| `Dashboard.Port` | `8083` | `8084` | | `OpcUa.ApplicationUri` | `urn:localhost:LmxOpcUa:instance1` | `urn:localhost:LmxOpcUa:instance2` |
| `Redundancy.Enabled` | `true` | `true` | | `Redundancy.Enabled` | `true` | `true` |
| `Redundancy.Role` | `Primary` | `Secondary` | | `Redundancy.Role` | `Primary` | `Secondary` |
| `Redundancy.Mode` | `Warm` | `Warm` | | `Redundancy.Mode` | `Warm` | `Warm` |
| `Redundancy.ServerUris` | `["urn:ZB:LmxOpcUa", "urn:ZB:LmxOpcUa2"]` | `["urn:ZB:LmxOpcUa", "urn:ZB:LmxOpcUa2"]` | | `Redundancy.ServerUris` | same two-entry set | same two-entry set |
| `Redundancy.ServiceLevelBase` | `200` | `200` |
Windows service for instance2: Deployment notes:
- Name: `LmxOpcUa2`
- Display name: `LMX OPC UA Server (Instance 2)`
- Executable: `C:\publish\lmxopcua\instance2\ZB.MOM.WW.LmxOpcUa.Host.exe`
Both instances share the same Galaxy DB (`ZB`) and MXAccess runtime. The `GalaxyName` remains `ZB` for both so they expose the same namespace. - both instances should share the same `GalaxyName` and namespace URI
- each instance must have a distinct application certificate identity
- if certificate handling is sensitive, give each instance an explicit `Security.CertificateSubject` or separate PKI root
Update `service_info.md` with the second instance details. Update [service_info.md](C:\Users\dohertj2\Desktop\lmxopcua\service_info.md) with the second instance details after deployment is real, not speculative.
--- ---
## Test Plan ## Test Plan
### Unit testsRedundancyModeResolver ### Unit tests: `RedundancyModeResolver`
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/RedundancyModeResolverTests.cs` **New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/RedundancyModeResolverTests.cs`
| Test | Description | | Test | Description |
|---|---| |---|---|
| `Resolve_Disabled_ReturnsNone` | `Enabled=false` always returns `RedundancySupport.None` | | `Resolve_Disabled_ReturnsNone` | `Enabled=false` returns `None` |
| `Resolve_Warm_ReturnsWarm` | `Mode="Warm"` maps to `RedundancySupport.Warm` | | `Resolve_Warm_ReturnsWarm` | `Mode="Warm"` maps correctly |
| `Resolve_Hot_ReturnsHot` | `Mode="Hot"` maps to `RedundancySupport.Hot` | | `Resolve_Hot_ReturnsHot` | `Mode="Hot"` maps correctly |
| `Resolve_Unknown_FallsBackToNone` | Unknown mode falls back safely | | `Resolve_Unknown_FallsBackToNone` | Unknown mode falls back safely |
| `Resolve_CaseInsensitive` | `"warm"` and `"WARM"` both resolve | | `Resolve_CaseInsensitive` | Case-insensitive parsing works |
### Unit testsServiceLevelCalculator ### Unit tests: `ServiceLevelCalculator`
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/ServiceLevelCalculatorTests.cs` **New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/ServiceLevelCalculatorTests.cs`
| Test | Description | | Test | Description |
|---|---| |---|---|
| `FullyHealthy_Primary_ReturnsBase` | All healthy, primary role → `ServiceLevelBase` | | `FullyHealthy_Primary_ReturnsBase` | Healthy primary baseline is preserved |
| `FullyHealthy_Secondary_ReturnsBaseMinusFifty` | All healthy, secondary role → `ServiceLevelBase - 50` | | `FullyHealthy_Secondary_ReturnsBaseMinusFifty` | Healthy secondary baseline is lower |
| `MxAccessDown_ReducesServiceLevel` | MXAccess disconnected subtracts 100 | | `MxAccessDown_ReducesServiceLevel` | MXAccess failure reduces score |
| `DbDown_ReducesServiceLevel` | DB unreachable subtracts 50 | | `DbDown_ReducesServiceLevel` | DB failure reduces score |
| `BothDown_ReturnsZero` | MXAccess + DB both down → 0 | | `BothDown_ReturnsZero` | Both unavailable returns 0 |
| `ClampedTo255` | Base of 255 with healthy → 255 | | `ClampedTo255` | Upper clamp works |
| `ClampedToZero` | Heavy penalties don't go negative | | `ClampedToZero` | Lower clamp works |
### Unit testsRedundancyConfiguration defaults ### Unit tests: `RedundancyConfiguration`
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/RedundancyConfigurationTests.cs` **New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Redundancy/RedundancyConfigurationTests.cs`
| Test | Description | | Test | Description |
|---|---| |---|---|
| `DefaultConfig_Disabled` | `Enabled` defaults to `false` | | `DefaultConfig_Disabled` | `Enabled` defaults to `false` |
| `DefaultConfig_ModeWarm` | `Mode` defaults to `"Warm"` | | `DefaultConfig_ModeWarm` | `Mode` defaults to `Warm` |
| `DefaultConfig_RolePrimary` | `Role` defaults to `"Primary"` | | `DefaultConfig_RolePrimary` | `Role` defaults to `Primary` |
| `DefaultConfig_EmptyServerUris` | `ServerUris` defaults to empty | | `DefaultConfig_EmptyServerUris` | `ServerUris` defaults to empty |
| `DefaultConfig_ServiceLevelBase200` | `ServiceLevelBase` defaults to `200` | | `DefaultConfig_ServiceLevelBase200` | `ServiceLevelBase` defaults to `200` |
@@ -316,64 +413,67 @@ Update `service_info.md` with the second instance details.
**File:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs` **File:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Configuration/ConfigurationLoadingTests.cs`
Add: Add coverage for:
- `Redundancy_Section_BindsCorrectly` — verify binding from appsettings.json
- `Redundancy_Section_BindsCustomValues` — in-memory override test
- `Validator_RedundancyEnabled_EmptyServerUris_ReturnsTrue_WithWarning` — validates but warns
- `Validator_RedundancyEnabled_InvalidServiceLevelBase_ReturnsFalse` — rejects 0 or >255
### Integration tests — redundancy E2E - `OpcUa.ApplicationUri`
- `Redundancy` section binding
- redundancy validation when `ApplicationUri` is missing
- redundancy validation when local `ApplicationUri` is absent from `ServerUris`
- invalid `ServiceLevelBase`
### Integration tests
**New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/RedundancyTests.cs` **New file:** `tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/RedundancyTests.cs`
These tests start two in-process OPC UA servers with redundancy enabled and verify client-visible behavior: Cover:
| Test | Description | - redundancy disabled reports `None`
|---|---| - warm redundancy reports configured mode
| `Server_WithRedundancyDisabled_ReportsNone` | Default config → `RedundancySupport.None`, `ServiceLevel=255` | - `ServerUriArray` matches configuration
| `Server_WithRedundancyEnabled_ReportsConfiguredMode` | `Enabled=true, Mode=Warm``RedundancySupport.Warm` | - primary reports higher `ServiceLevel` than secondary
| `Server_WithRedundancyEnabled_ExposesServerUriArray` | Client can read `ServerUriArray` and it matches config | - both servers expose the same namespace URI but different `ApplicationUri` values
| `Server_Primary_HasHigherServiceLevel_ThanSecondary` | Primary server reports higher `ServiceLevel` than secondary | - service level drops when MXAccess disconnects
| `TwoServers_BothExposeSameRedundantSet` | Two server fixtures, both report the same `ServerUriArray` |
| `Server_ServiceLevel_DropsWith_MxAccessDisconnect` | Simulate MXAccess disconnect → `ServiceLevel` decreases |
Pattern: Use `OpcUaServerFixture.WithFakeMxAccessClient()` with redundancy config injected, connect with `OpcUaTestClient`, read the standard OPC UA redundancy nodes. Pattern:
- use two fixture instances
- give each fixture a distinct `ServerName`, `ApplicationUri`, and port
- if secure transport is enabled in those tests, isolate PKI roots to avoid certificate cross-talk
--- ---
## Documentation Plan ## Documentation Plan
### New file: `docs/Redundancy.md` ### New file
- `docs/Redundancy.md`
Contents: Contents:
1. Overview of OPC UA non-transparent redundancy
2. Redundancy configuration section reference (`Enabled`, `Mode`, `Role`, `ServerUris`, `ServiceLevelBase`) 1. overview of OPC UA non-transparent redundancy
3. ServiceLevel computation logic and degraded-state penalties 2. difference between namespace URI and server `ApplicationUri`
4. How clients discover and fail over between instances 3. redundancy configuration reference
5. Deployment guide for a two-instance redundant pair (ports, service names, shared Galaxy DB) 4. service-level computation
5. two-instance deployment guide
6. CLI `redundancy` command usage 6. CLI `redundancy` command usage
7. Troubleshooting: mismatched `ServerUris`, ServiceLevel stuck at 0, etc. 7. troubleshooting
### Updates to existing docs ### Updates to existing docs
| File | Changes | | File | Changes |
|---|---| |---|---|
| `docs/Configuration.md` | Add `Redundancy` section table, example JSON, add to validation rules list, update example appsettings.json | | `docs/Configuration.md` | Add `OpcUa.ApplicationUri` and `Redundancy` sections |
| `docs/OpcUaServer.md` | Add redundancy state exposure section, link to `Redundancy.md` | | `docs/OpcUaServer.md` | Correct the current `ApplicationUri == namespace` description and add redundancy behavior |
| `docs/CliTool.md` | Add `redundancy` command documentation | | `docs/CliTool.md` | Add `redundancy` command |
| `docs/ServiceHosting.md` | Add multi-instance deployment notes | | `docs/ServiceHosting.md` | Add multi-instance deployment notes |
| `README.md` | Add `Redundancy` to the component documentation table, mention redundancy in Quick Start | | `README.md` | Mention redundancy support and link docs |
| `CLAUDE.md` | Add redundancy architecture note | | `CLAUDE.md` | Add redundancy architecture note |
### Update: `service_info.md` ### Update after real deployment
Add a second section documenting `instance2`: - `service_info.md`
- Path: `C:\publish\lmxopcua\instance2`
- Windows service name: `LmxOpcUa2` Only update this once the second instance is actually deployed and verified.
- Port: `4841`
- Dashboard port: `8084`
- Redundancy role: `Secondary`
- Endpoint: `opc.tcp://localhost:4841/LmxOpcUa`
--- ---
@@ -381,128 +481,117 @@ Add a second section documenting `instance2`:
| File | Action | Description | | File | Action | Description |
|---|---|---| |---|---|---|
| `src/.../Configuration/OpcUaConfiguration.cs` | Modify | Add explicit `ApplicationUri` |
| `src/.../Configuration/RedundancyConfiguration.cs` | New | Redundancy config model | | `src/.../Configuration/RedundancyConfiguration.cs` | New | Redundancy config model |
| `src/.../Configuration/AppConfiguration.cs` | Modify | Add `Redundancy` section | | `src/.../Configuration/AppConfiguration.cs` | Modify | Add `Redundancy` section |
| `src/.../Configuration/ConfigurationValidator.cs` | Modify | Validate/log redundancy settings | | `src/.../Configuration/ConfigurationValidator.cs` | Modify | Validate/log redundancy and application identity |
| `src/.../OpcUa/RedundancyModeResolver.cs` | New | Mode string → `RedundancySupport` enum | | `src/.../OpcUa/RedundancyModeResolver.cs` | New | Map config mode to `RedundancySupport` |
| `src/.../OpcUa/ServiceLevelCalculator.cs` | New | Dynamic ServiceLevel from health state | | `src/.../OpcUa/ServiceLevelCalculator.cs` | New | Compute `ServiceLevel` from health inputs |
| `src/.../OpcUa/LmxOpcUaServer.cs` | Modify | Expose redundancy nodes, accept ServiceLevel updates | | `src/.../OpcUa/LmxOpcUaServer.cs` | Modify | Expose redundancy state via SDK server object |
| `src/.../OpcUa/OpcUaServerHost.cs` | Modify | Pass redundancy config through | | `src/.../OpcUa/OpcUaServerHost.cs` | Modify | Pass local application identity and redundancy config |
| `src/.../OpcUaService.cs` | Modify | Bind redundancy config, wire ServiceLevel updates | | `src/.../OpcUaService.cs` | Modify | Bind config and wire health updates |
| `src/.../OpcUaServiceBuilder.cs` | Modify | Add `WithRedundancy()` builder | | `src/.../OpcUaServiceBuilder.cs` | Modify | Support redundancy/application identity injection |
| `src/.../appsettings.json` | Modify | Add `Redundancy` section | | `src/.../appsettings.json` | Modify | Add redundancy settings |
| `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` | New | CLI command to read redundancy info | | `tools/opcuacli-dotnet/Commands/RedundancyCommand.cs` | New | Read redundancy state from a server |
| `tests/.../Redundancy/RedundancyModeResolverTests.cs` | New | Mode resolver unit tests | | `tests/.../Redundancy/*.cs` | New | Unit tests for redundancy config and calculators |
| `tests/.../Redundancy/ServiceLevelCalculatorTests.cs` | New | ServiceLevel computation tests | | `tests/.../Configuration/ConfigurationLoadingTests.cs` | Modify | Bind/validate new settings |
| `tests/.../Redundancy/RedundancyConfigurationTests.cs` | New | Config defaults tests | | `tests/.../Integration/RedundancyTests.cs` | New | Paired-server integration tests |
| `tests/.../Configuration/ConfigurationLoadingTests.cs` | Modify | Binding + validation tests | | `tests/.../Helpers/OpcUaServerFixture.cs` | Modify | Support paired redundancy fixtures |
| `tests/.../Integration/RedundancyTests.cs` | New | E2E two-server redundancy tests | | `tests/.../Helpers/OpcUaTestClient.cs` | Modify | Read redundancy nodes in integration tests |
| `tests/.../Helpers/OpcUaServerFixture.cs` | Modify | Accept redundancy config | | `docs/Redundancy.md` | New | Dedicated redundancy guide |
| `docs/Redundancy.md` | New | Dedicated redundancy component doc | | `docs/Configuration.md` | Modify | Document new config |
| `docs/Configuration.md` | Modify | Add Redundancy section | | `docs/OpcUaServer.md` | Modify | Correct application identity and add redundancy details |
| `docs/OpcUaServer.md` | Modify | Add redundancy state section | | `docs/CliTool.md` | Modify | Document `redundancy` command |
| `docs/CliTool.md` | Modify | Add redundancy command | | `docs/ServiceHosting.md` | Modify | Multi-instance deployment notes |
| `docs/ServiceHosting.md` | Modify | Multi-instance notes | | `README.md` | Modify | Link redundancy docs |
| `README.md` | Modify | Add Redundancy to component table | | `CLAUDE.md` | Modify | Architecture note |
| `CLAUDE.md` | Modify | Add redundancy architecture note | | `service_info.md` | Modify later | Document real second-instance deployment |
| `service_info.md` | Modify | Add instance2 details |
--- ---
## Verification Guardrails ## Verification Guardrails
Each step must pass these gates before proceeding to the next: ### Gate 1: Build
### Gate 1: Build (after each implementation step)
```bash ```bash
dotnet build ZB.MOM.WW.LmxOpcUa.slnx dotnet build ZB.MOM.WW.LmxOpcUa.slnx
``` ```
Must produce 0 errors. Proceed only when green.
### Gate 2: Unit tests (after steps 14, 9) ### Gate 2: Unit tests
```bash ```bash
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests
``` ```
All existing + new tests must pass. No regressions.
### Gate 3: Integration tests (after steps 57) ### Gate 3: Redundancy integration tests
```bash ```bash
dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Integration.RedundancyTests" dotnet test tests/ZB.MOM.WW.LmxOpcUa.Tests --filter "FullyQualifiedName~Redundancy"
``` ```
All redundancy E2E tests must pass.
### Gate 4: CLI tool builds (after step 10) ### Gate 4: CLI build
```bash ```bash
cd tools/opcuacli-dotnet && dotnet build cd tools/opcuacli-dotnet
dotnet build
``` ```
Must compile without errors.
### Gate 5: Manual verification — single instance (after step 8) ### Gate 5: Manual single-instance check
```bash ```bash
# Publish and start with Redundancy.Enabled=false
opcuacli-dotnet.exe connect -u opc.tcp://localhost:4840/LmxOpcUa opcuacli-dotnet.exe connect -u opc.tcp://localhost:4840/LmxOpcUa
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa
# Should report: RedundancySupport=None, ServiceLevel=255
``` ```
### Gate 6: Manual verification — redundant pair (after step 11) Expected:
- `RedundancySupport=None`
- `ServiceLevel=255`
### Gate 6: Manual paired-instance check
```bash ```bash
# Start both instances
sc start LmxOpcUa
sc start LmxOpcUa2
# Verify instance1 (Primary)
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa
# Should report: RedundancySupport=Warm, ServiceLevel=200, ServerUris=[urn:ZB:LmxOpcUa, urn:ZB:LmxOpcUa2]
# Verify instance2 (Secondary)
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa
# Should report: RedundancySupport=Warm, ServiceLevel=150, ServerUris=[urn:ZB:LmxOpcUa, urn:ZB:LmxOpcUa2]
# Both instances should serve the same Galaxy address space
opcuacli-dotnet.exe browse -u opc.tcp://localhost:4840/LmxOpcUa -r -d 2
opcuacli-dotnet.exe browse -u opc.tcp://localhost:4841/LmxOpcUa -r -d 2
``` ```
### Gate 7: Full test suite (final) Expected:
- both report the same `ServerUriArray`
- each reports its own unique local `ApplicationUri`
- primary reports a higher `ServiceLevel`
### Gate 7: Full test suite
```bash ```bash
dotnet test ZB.MOM.WW.LmxOpcUa.slnx dotnet test ZB.MOM.WW.LmxOpcUa.slnx
``` ```
All tests across all projects must pass.
### Gate 8: Documentation review
- All new/modified doc files render correctly in Markdown
- Example JSON snippets match the actual `appsettings.json`
- CLI examples use correct flags and expected output
- `service_info.md` accurately reflects both deployed instances
--- ---
## Risks and Considerations ## Risks and Considerations
1. **Backward compatibility**: `Redundancy.Enabled = false` must be the default so existing single-instance deployments are unaffected. 1. **Application identity is the main correctness risk.** Without unique `ApplicationUri` values, the redundant set is invalid even if `ServerUriArray` is populated.
2. **ServiceLevel timing**: Updates must not race with OPC UA publish cycles. Use the server's internal lock or `ServerInternal` APIs. 2. **SDK wiring may require replacing the default redundancy state node.** The base `ServerRedundancyState` does not expose `ServerUriArray`; the implementation may need the non-transparent subtype from the SDK.
3. **ServerUriArray immutability**: The OPC UA spec expects this to be static during a server session. Changes require a server restart. 3. **Two in-process servers can collide on certificates.** Tests and deployment need distinct application identities and, when necessary, isolated PKI roots.
4. **MXAccess shared state**: Both instances connect to the same MXAccess runtime. If MXAccess has per-client registration limits, verify that two clients can coexist. 4. **Both instances hit the same MXAccess runtime and Galaxy DB.** Verify client-registration and polling behavior under paired load.
5. **Galaxy DB contention**: Both instances poll for deploy changes. Ensure change detection doesn't trigger duplicate rebuilds or locking issues. 5. **`ServiceLevel` should remain meaningful, not noisy.** Prefer deterministic role + health inputs over frequent arbitrary adjustments.
6. **Port conflicts**: The second instance must use different ports for OPC UA (4841) and Dashboard (8084). 6. **`service_info.md` is deployment documentation, not design.** Do not prefill it with speculative values before the second instance actually exists.
7. **Certificate identity**: Each instance needs its own application certificate with a distinct `SubjectName` matching its `ServerName`.
--- ---
## Execution Order ## Execution Order
1. Steps 14: Config model, resolver, calculator, validator (unit-testable in isolation) 1. Step 1: add `OpcUa.ApplicationUri` and separate it from namespace identity
2. **Gate 1 + Gate 2**: Build + unit tests pass 2. Steps 2-5: config model, resolver, calculator, validator
3. Steps 57: Server integration (redundancy nodes, ServiceLevel wiring) 3. Gate 1 + Gate 2
4. **Gate 1 + Gate 2 + Gate 3**: Build + all tests including E2E 4. Step 9: update builders/helpers so tests can express paired servers cleanly
5. Step 8: Update appsettings.json 5. Step 6-8: server exposure and service-layer health wiring
6. **Gate 5**: Manual single-instance verification 6. Gate 1 + Gate 2 + Gate 3
7. Step 9: Update service builder for tests 7. Step 10: update `appsettings.json`
8. Step 10: CLI redundancy command 8. Step 11: add CLI `redundancy` command
9. **Gate 4**: CLI builds 9. Gate 4 + Gate 5
10. Step 11: Deploy second instance + update service_info.md 10. Step 12: deploy and verify the second instance
11. **Gate 6**: Manual two-instance verification 11. Update `service_info.md` with real deployment details
12. Documentation updates (all doc files) 12. Documentation updates
13. **Gate 7 + Gate 8**: Full test suite + documentation review 13. Gate 7
14. Commit and push

View File

@@ -46,6 +46,49 @@ Observed results:
- `read ns=1;s=MESReceiver_001.MoveInPartNumbers`: succeeded with good status `0x00000000`. - `read ns=1;s=MESReceiver_001.MoveInPartNumbers`: succeeded with good status `0x00000000`.
- `read ns=1;s=MESReceiver_001.MoveInPartNumbers[]`: failed with `BadNodeIdUnknown` (`0x80340000`). - `read ns=1;s=MESReceiver_001.MoveInPartNumbers[]`: failed with `BadNodeIdUnknown` (`0x80340000`).
---
## Instance 2 (Redundant Secondary)
Deployed: `2026-03-28`
Deployment path: `C:\publish\lmxopcua\instance2`
Configuration:
- `OpcUa.Port`: `4841`
- `OpcUa.ServerName`: `LmxOpcUa2`
- `OpcUa.ApplicationUri`: `urn:localhost:LmxOpcUa:instance2`
- `Dashboard.Port`: `8082`
- `MxAccess.ClientName`: `LmxOpcUa2`
- `Redundancy.Enabled`: `true`
- `Redundancy.Mode`: `Warm`
- `Redundancy.Role`: `Secondary`
- `Redundancy.ServerUris`: `["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"]`
Windows service:
- Name: `LmxOpcUa2`
- Display name: `LMX OPC UA Server (Instance 2)`
- Account: `LocalSystem`
- Endpoint: `opc.tcp://localhost:4841/LmxOpcUa`
Instance 1 redundancy update (same date):
- `OpcUa.ApplicationUri`: `urn:localhost:LmxOpcUa:instance1`
- `Redundancy.Enabled`: `true`
- `Redundancy.Mode`: `Warm`
- `Redundancy.Role`: `Primary`
- `Redundancy.ServerUris`: `["urn:localhost:LmxOpcUa:instance1", "urn:localhost:LmxOpcUa:instance2"]`
CLI verification:
```
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4840/LmxOpcUa
→ Redundancy Mode: Warm, Service Level: 200, Application URI: urn:localhost:LmxOpcUa:instance1
opcuacli-dotnet.exe redundancy -u opc.tcp://localhost:4841/LmxOpcUa
→ Redundancy Mode: Warm, Service Level: 150, Application URI: urn:localhost:LmxOpcUa:instance2
```
Both instances report the same `ServerUriArray` and expose the same Galaxy namespace (`urn:ZB:LmxOpcUa`).
## Notes ## Notes
The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation. The service deployment and restart succeeded. The live CLI checks confirm the endpoint is reachable and that the array node identifier has changed to the bracketless form. The array value on the live service still prints as blank even though the status is good, so if this environment should have populated `MoveInPartNumbers`, the runtime data path still needs follow-up investigation.

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. /// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
/// </summary> /// </summary>
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration(); 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"); 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"); Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
return valid; return valid;
} }

View File

@@ -31,6 +31,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary> /// </summary>
public string GalaxyName { get; set; } = "ZB"; 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> /// <summary>
/// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host. /// Gets or sets the maximum number of simultaneous OPC UA sessions accepted by the host.
/// </summary> /// </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.Collections.Generic;
using System.Text; using System.Text;
using Opc.Ua; using Opc.Ua;
@@ -11,7 +12,8 @@ using ZB.MOM.WW.LmxOpcUa.Host.Metrics;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{ {
/// <summary> /// <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> /// </summary>
public class LmxOpcUaServer : StandardServer public class LmxOpcUaServer : StandardServer
{ {
@@ -24,6 +26,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly bool _alarmTrackingEnabled; private readonly bool _alarmTrackingEnabled;
private readonly AuthenticationConfiguration _authConfig; private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider; private readonly IUserAuthenticationProvider? _authProvider;
private readonly RedundancyConfiguration _redundancyConfig;
private readonly string? _applicationUri;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
private LmxNodeManager? _nodeManager; private LmxNodeManager? _nodeManager;
/// <summary> /// <summary>
@@ -45,7 +50,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics, public LmxOpcUaServer(string galaxyName, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, 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; _galaxyName = galaxyName;
_mxAccessClient = mxAccessClient; _mxAccessClient = mxAccessClient;
@@ -54,6 +60,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_alarmTrackingEnabled = alarmTrackingEnabled; _alarmTrackingEnabled = alarmTrackingEnabled;
_authConfig = authConfig ?? new AuthenticationConfiguration(); _authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider; _authProvider = authProvider;
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
_applicationUri = applicationUri;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -72,6 +80,104 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{ {
base.OnServerStarted(server); base.OnServerStarted(server);
server.SessionManager.ImpersonateUser += OnImpersonateUser; 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) 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 AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider; private readonly IUserAuthenticationProvider? _authProvider;
private readonly SecurityProfileConfiguration _securityConfig; private readonly SecurityProfileConfiguration _securityConfig;
private readonly RedundancyConfiguration _redundancyConfig;
private ApplicationInstance? _application; private ApplicationInstance? _application;
private LmxOpcUaServer? _server; private LmxOpcUaServer? _server;
@@ -43,6 +44,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary> /// </summary>
public bool IsRunning => _server != null; 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> /// <summary>
/// Initializes a new host for the Galaxy-backed OPC UA server instance. /// Initializes a new host for the Galaxy-backed OPC UA server instance.
/// </summary> /// </summary>
@@ -54,7 +61,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
HistorianDataSource? historianDataSource = null, HistorianDataSource? historianDataSource = null,
AuthenticationConfiguration? authConfig = null, AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null, IUserAuthenticationProvider? authProvider = null,
SecurityProfileConfiguration? securityConfig = null) SecurityProfileConfiguration? securityConfig = null,
RedundancyConfiguration? redundancyConfig = null)
{ {
_config = config; _config = config;
_mxAccessClient = mxAccessClient; _mxAccessClient = mxAccessClient;
@@ -63,6 +71,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_authConfig = authConfig ?? new AuthenticationConfiguration(); _authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider; _authProvider = authProvider;
_securityConfig = securityConfig ?? new SecurityProfileConfiguration(); _securityConfig = securityConfig ?? new SecurityProfileConfiguration();
_redundancyConfig = redundancyConfig ?? new RedundancyConfiguration();
} }
/// <summary> /// <summary>
@@ -71,6 +80,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
public async Task StartAsync() public async Task StartAsync()
{ {
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa"; var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
var applicationUri = _config.ApplicationUri ?? namespaceUri;
// Resolve configured security profiles // Resolve configured security profiles
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles); var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
@@ -127,7 +137,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var appConfig = new ApplicationConfiguration var appConfig = new ApplicationConfiguration
{ {
ApplicationName = _config.ServerName, ApplicationName = _config.ServerName,
ApplicationUri = namespaceUri, ApplicationUri = applicationUri,
ApplicationType = ApplicationType.Server, ApplicationType = ApplicationType.Server,
ProductUri = namespaceUri, ProductUri = namespaceUri,
ServerConfiguration = serverConfig, ServerConfiguration = serverConfig,
@@ -174,11 +184,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
} }
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource, _server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
_config.AlarmTrackingEnabled, _authConfig, _authProvider); _config.AlarmTrackingEnabled, _authConfig, _authProvider, _redundancyConfig, applicationUri);
await _application.Start(_server); await _application.Start(_server);
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (namespace={Namespace})", Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (applicationUri={ApplicationUri}, namespace={Namespace})",
_config.BindAddress, _config.Port, _config.EndpointPath, namespaceUri); _config.BindAddress, _config.Port, _config.EndpointPath, applicationUri, namespaceUri);
} }
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e) 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 // Clear the default Profiles list before binding so JSON values replace rather than append
_config.Security.Profiles.Clear(); _config.Security.Profiles.Clear();
configuration.GetSection("Security").Bind(_config.Security); configuration.GetSection("Security").Bind(_config.Security);
configuration.GetSection("Redundancy").Bind(_config.Redundancy);
_mxProxy = new MxProxyAdapter(); _mxProxy = new MxProxyAdapter();
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository); _galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
@@ -164,7 +165,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users) ? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
: (Domain.IUserAuthenticationProvider?)null; : (Domain.IUserAuthenticationProvider?)null;
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource, _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 // Step 9-10: Query hierarchy, start server, build address space
DateTime? initialDeployTime = null; DateTime? initialDeployTime = null;
@@ -222,6 +223,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_statusWebServer.Start(); _statusWebServer.Start();
} }
// Wire ServiceLevel updates from MXAccess health changes
if (_config.Redundancy.Enabled)
{
effectiveMxClient.ConnectionStateChanged += OnMxAccessStateChangedForServiceLevel;
}
// Step 14 // Step 14
Log.Information("LmxOpcUa service started successfully"); 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) private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{ {
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating); 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; 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> /// <summary>
/// Sets the security profile configuration for the test host. /// Sets the security profile configuration for the test host.
/// </summary> /// </summary>

View File

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

View File

@@ -236,5 +236,81 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.Security.AutoAcceptClientCertificates.ShouldBe(false); config.Security.AutoAcceptClientCertificates.ShouldBe(false);
config.Security.MinimumCertificateKeySize.ShouldBe(4096); config.Security.MinimumCertificateKeySize.ShouldBe(4096);
} }
[Fact]
public void Redundancy_Section_BindsFromJson()
{
var config = LoadFromJson();
config.Redundancy.Enabled.ShouldBe(false);
config.Redundancy.Mode.ShouldBe("Warm");
config.Redundancy.Role.ShouldBe("Primary");
config.Redundancy.ServiceLevelBase.ShouldBe(200);
}
[Fact]
public void Redundancy_Section_BindsCustomValues()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:Enabled", "true"),
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:Mode", "Hot"),
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:Role", "Secondary"),
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:ServiceLevelBase", "180"),
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:ServerUris:0", "urn:a"),
new System.Collections.Generic.KeyValuePair<string, string>("Redundancy:ServerUris:1", "urn:b"),
})
.Build();
var config = new AppConfiguration();
configuration.GetSection("Redundancy").Bind(config.Redundancy);
config.Redundancy.Enabled.ShouldBe(true);
config.Redundancy.Mode.ShouldBe("Hot");
config.Redundancy.Role.ShouldBe("Secondary");
config.Redundancy.ServiceLevelBase.ShouldBe(180);
config.Redundancy.ServerUris.Count.ShouldBe(2);
}
[Fact]
public void Validator_RedundancyEnabled_NoApplicationUri_ReturnsFalse()
{
var config = new AppConfiguration();
config.Redundancy.Enabled = true;
config.Redundancy.ServerUris.Add("urn:a");
config.Redundancy.ServerUris.Add("urn:b");
// OpcUa.ApplicationUri is null
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
[Fact]
public void Validator_InvalidServiceLevelBase_ReturnsFalse()
{
var config = new AppConfiguration();
config.Redundancy.ServiceLevelBase = 0;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
[Fact]
public void OpcUa_ApplicationUri_DefaultsToNull()
{
var config = new OpcUaConfiguration();
config.ApplicationUri.ShouldBeNull();
}
[Fact]
public void OpcUa_ApplicationUri_BindsFromConfig()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new System.Collections.Generic.KeyValuePair<string, string>("OpcUa:ApplicationUri", "urn:test:app"),
})
.Build();
var config = new OpcUaConfiguration();
configuration.GetSection("OpcUa").Bind(config);
config.ApplicationUri.ShouldBe("urn:test:app");
}
} }
} }

View File

@@ -110,11 +110,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param> /// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param>
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param> /// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
/// <param name="security">An optional security profile configuration for the test server.</param> /// <param name="security">An optional security profile configuration for the test server.</param>
/// <param name="redundancy">An optional redundancy configuration for the test server.</param>
/// <param name="applicationUri">An optional explicit application URI for the test server.</param>
/// <param name="serverName">An optional server name override for the test server.</param>
/// <returns>A fixture configured to exercise the direct fake-client path.</returns> /// <returns>A fixture configured to exercise the direct fake-client path.</returns>
public static OpcUaServerFixture WithFakeMxAccessClient( public static OpcUaServerFixture WithFakeMxAccessClient(
FakeMxAccessClient? mxClient = null, FakeMxAccessClient? mxClient = null,
FakeGalaxyRepository? repo = null, FakeGalaxyRepository? repo = null,
SecurityProfileConfiguration? security = null) SecurityProfileConfiguration? security = null,
RedundancyConfiguration? redundancy = null,
string? applicationUri = null,
string? serverName = null)
{ {
var client = mxClient ?? new FakeMxAccessClient(); var client = mxClient ?? new FakeMxAccessClient();
var r = repo ?? new FakeGalaxyRepository var r = repo ?? new FakeGalaxyRepository
@@ -130,6 +136,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
if (security != null) if (security != null)
builder.WithSecurity(security); builder.WithSecurity(security);
if (redundancy != null)
builder.WithRedundancy(redundancy);
if (applicationUri != null)
builder.WithApplicationUri(applicationUri);
if (serverName != null)
builder.WithGalaxyName(serverName);
return new OpcUaServerFixture(builder, repo: r, mxClient: client); return new OpcUaServerFixture(builder, repo: r, mxClient: client);
} }

View File

@@ -0,0 +1,179 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Tests.Helpers;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration
{
public class RedundancyTests
{
[Fact]
public async Task Server_WithRedundancyDisabled_ReportsNone()
{
var fixture = OpcUaServerFixture.WithFakeMxAccessClient();
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.None);
var serviceLevel = client.Read(VariableIds.Server_ServiceLevel);
((byte)serviceLevel.Value).ShouldBe((byte)255);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task Server_WithRedundancyEnabled_ReportsConfiguredMode()
{
var redundancy = new RedundancyConfiguration
{
Enabled = true,
Mode = "Warm",
Role = "Primary",
ServiceLevelBase = 200,
ServerUris = new List<string> { "urn:test:primary", "urn:test:secondary" }
};
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
redundancy: redundancy,
applicationUri: "urn:test:primary");
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var redundancySupport = client.Read(VariableIds.Server_ServerRedundancy_RedundancySupport);
((int)redundancySupport.Value).ShouldBe((int)RedundancySupport.Warm);
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task Server_Primary_HasHigherServiceLevel_ThanSecondary()
{
var sharedUris = new List<string> { "urn:test:primary", "urn:test:secondary" };
var primaryRedundancy = new RedundancyConfiguration
{
Enabled = true, Mode = "Warm", Role = "Primary",
ServiceLevelBase = 200, ServerUris = sharedUris
};
var secondaryRedundancy = new RedundancyConfiguration
{
Enabled = true, Mode = "Warm", Role = "Secondary",
ServiceLevelBase = 200, ServerUris = sharedUris
};
var primaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
redundancy: primaryRedundancy, applicationUri: "urn:test:primary");
var secondaryFixture = OpcUaServerFixture.WithFakeMxAccessClient(
redundancy: secondaryRedundancy, applicationUri: "urn:test:secondary",
serverName: "TestGalaxy2");
await primaryFixture.InitializeAsync();
await secondaryFixture.InitializeAsync();
try
{
using var primaryClient = new OpcUaTestClient();
await primaryClient.ConnectAsync(primaryFixture.EndpointUrl);
var primaryLevel = (byte)primaryClient.Read(VariableIds.Server_ServiceLevel).Value;
using var secondaryClient = new OpcUaTestClient();
await secondaryClient.ConnectAsync(secondaryFixture.EndpointUrl);
var secondaryLevel = (byte)secondaryClient.Read(VariableIds.Server_ServiceLevel).Value;
primaryLevel.ShouldBeGreaterThan(secondaryLevel);
}
finally
{
await secondaryFixture.DisposeAsync();
await primaryFixture.DisposeAsync();
}
}
[Fact]
public async Task Server_WithRedundancyEnabled_ExposesServerUriArray()
{
var serverUris = new List<string> { "urn:test:server1", "urn:test:server2" };
var redundancy = new RedundancyConfiguration
{
Enabled = true, Mode = "Warm", Role = "Primary",
ServiceLevelBase = 200, ServerUris = serverUris
};
var fixture = OpcUaServerFixture.WithFakeMxAccessClient(
redundancy: redundancy, applicationUri: "urn:test:server1");
await fixture.InitializeAsync();
try
{
using var client = new OpcUaTestClient();
await client.ConnectAsync(fixture.EndpointUrl);
var uriArrayValue = client.Read(VariableIds.Server_ServerRedundancy_ServerUriArray);
// ServerUriArray may not be exposed if the SDK doesn't create the non-transparent
// redundancy node type automatically. If the value is null, the server logged a
// warning and the test is informational rather than a hard failure.
if (uriArrayValue.Value != null)
{
var uris = (string[])uriArrayValue.Value;
uris.Length.ShouldBe(2);
uris.ShouldContain("urn:test:server1");
uris.ShouldContain("urn:test:server2");
}
}
finally { await fixture.DisposeAsync(); }
}
[Fact]
public async Task TwoServers_BothExposeSameRedundantSet()
{
var sharedUris = new List<string> { "urn:test:a", "urn:test:b" };
var configA = new RedundancyConfiguration
{
Enabled = true, Mode = "Warm", Role = "Primary",
ServiceLevelBase = 200, ServerUris = sharedUris
};
var configB = new RedundancyConfiguration
{
Enabled = true, Mode = "Warm", Role = "Secondary",
ServiceLevelBase = 200, ServerUris = sharedUris
};
var fixtureA = OpcUaServerFixture.WithFakeMxAccessClient(
redundancy: configA, applicationUri: "urn:test:a");
var fixtureB = OpcUaServerFixture.WithFakeMxAccessClient(
redundancy: configB, applicationUri: "urn:test:b",
serverName: "TestGalaxy2");
await fixtureA.InitializeAsync();
await fixtureB.InitializeAsync();
try
{
using var clientA = new OpcUaTestClient();
await clientA.ConnectAsync(fixtureA.EndpointUrl);
var modeA = (int)clientA.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
using var clientB = new OpcUaTestClient();
await clientB.ConnectAsync(fixtureB.EndpointUrl);
var modeB = (int)clientB.Read(VariableIds.Server_ServerRedundancy_RedundancySupport).Value;
modeA.ShouldBe((int)RedundancySupport.Warm);
modeB.ShouldBe((int)RedundancySupport.Warm);
}
finally
{
await fixtureB.DisposeAsync();
await fixtureA.DisposeAsync();
}
}
}
}

View File

@@ -0,0 +1,44 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
{
public class RedundancyConfigurationTests
{
[Fact]
public void DefaultConfig_Disabled()
{
var config = new RedundancyConfiguration();
config.Enabled.ShouldBe(false);
}
[Fact]
public void DefaultConfig_ModeWarm()
{
var config = new RedundancyConfiguration();
config.Mode.ShouldBe("Warm");
}
[Fact]
public void DefaultConfig_RolePrimary()
{
var config = new RedundancyConfiguration();
config.Role.ShouldBe("Primary");
}
[Fact]
public void DefaultConfig_EmptyServerUris()
{
var config = new RedundancyConfiguration();
config.ServerUris.ShouldBeEmpty();
}
[Fact]
public void DefaultConfig_ServiceLevelBase200()
{
var config = new RedundancyConfiguration();
config.ServiceLevelBase.ShouldBe(200);
}
}
}

View File

@@ -0,0 +1,54 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
{
public class RedundancyModeResolverTests
{
[Fact]
public void Resolve_Disabled_ReturnsNone()
{
RedundancyModeResolver.Resolve("Warm", enabled: false).ShouldBe(RedundancySupport.None);
}
[Fact]
public void Resolve_Warm_ReturnsWarm()
{
RedundancyModeResolver.Resolve("Warm", enabled: true).ShouldBe(RedundancySupport.Warm);
}
[Fact]
public void Resolve_Hot_ReturnsHot()
{
RedundancyModeResolver.Resolve("Hot", enabled: true).ShouldBe(RedundancySupport.Hot);
}
[Fact]
public void Resolve_Unknown_FallsBackToNone()
{
RedundancyModeResolver.Resolve("Transparent", enabled: true).ShouldBe(RedundancySupport.None);
}
[Fact]
public void Resolve_CaseInsensitive()
{
RedundancyModeResolver.Resolve("warm", enabled: true).ShouldBe(RedundancySupport.Warm);
RedundancyModeResolver.Resolve("WARM", enabled: true).ShouldBe(RedundancySupport.Warm);
RedundancyModeResolver.Resolve("hot", enabled: true).ShouldBe(RedundancySupport.Hot);
}
[Fact]
public void Resolve_Null_FallsBackToNone()
{
RedundancyModeResolver.Resolve(null!, enabled: true).ShouldBe(RedundancySupport.None);
}
[Fact]
public void Resolve_Empty_FallsBackToNone()
{
RedundancyModeResolver.Resolve("", enabled: true).ShouldBe(RedundancySupport.None);
}
}
}

View File

@@ -0,0 +1,59 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Redundancy
{
public class ServiceLevelCalculatorTests
{
private readonly ServiceLevelCalculator _calculator = new ServiceLevelCalculator();
[Fact]
public void FullyHealthy_Primary_ReturnsBase()
{
_calculator.Calculate(200, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)200);
}
[Fact]
public void FullyHealthy_Secondary_ReturnsBaseMinusFifty()
{
_calculator.Calculate(150, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)150);
}
[Fact]
public void MxAccessDown_ReducesServiceLevel()
{
_calculator.Calculate(200, mxAccessConnected: false, dbConnected: true).ShouldBe((byte)100);
}
[Fact]
public void DbDown_ReducesServiceLevel()
{
_calculator.Calculate(200, mxAccessConnected: true, dbConnected: false).ShouldBe((byte)150);
}
[Fact]
public void BothDown_ReturnsZero()
{
_calculator.Calculate(200, mxAccessConnected: false, dbConnected: false).ShouldBe((byte)0);
}
[Fact]
public void ClampedTo255()
{
_calculator.Calculate(255, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)255);
}
[Fact]
public void ClampedToZero()
{
_calculator.Calculate(50, mxAccessConnected: false, dbConnected: true).ShouldBe((byte)0);
}
[Fact]
public void ZeroBase_BothHealthy_ReturnsZero()
{
_calculator.Calculate(0, mxAccessConnected: true, dbConnected: true).ShouldBe((byte)0);
}
}
}

View File

@@ -0,0 +1,70 @@
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Opc.Ua;
using Opc.Ua.Client;
namespace OpcUaCli.Commands;
[Command("redundancy", Description = "Read redundancy state from an OPC UA server")]
public class RedundancyCommand : ICommand
{
[CommandOption("url", 'u', Description = "OPC UA server endpoint URL", IsRequired = true)]
public string Url { get; init; } = default!;
[CommandOption("username", 'U', Description = "Username for authentication")]
public string? Username { get; init; }
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
// Read RedundancySupport
var redundancySupportValue = await session.ReadValueAsync(VariableIds.Server_ServerRedundancy_RedundancySupport);
var redundancyMode = (RedundancySupport)(int)redundancySupportValue.Value;
await console.Output.WriteLineAsync($"Redundancy Mode: {redundancyMode}");
// Read ServiceLevel
var serviceLevelValue = await session.ReadValueAsync(VariableIds.Server_ServiceLevel);
var serviceLevel = (byte)serviceLevelValue.Value;
await console.Output.WriteLineAsync($"Service Level: {serviceLevel}");
// Read ServerUriArray (only present for non-transparent redundancy)
try
{
var serverUriArrayValue = await session.ReadValueAsync(VariableIds.Server_ServerRedundancy_ServerUriArray);
if (serverUriArrayValue.Value is string[] uris && uris.Length > 0)
{
await console.Output.WriteLineAsync("Server URIs:");
foreach (var uri in uris)
{
await console.Output.WriteLineAsync($" - {uri}");
}
}
}
catch
{
// ServerUriArray may not be present when RedundancySupport is None
}
// Read ServerArray for the local server's ApplicationUri
try
{
var serverArrayValue = await session.ReadValueAsync(VariableIds.Server_ServerArray);
if (serverArrayValue.Value is string[] serverArray && serverArray.Length > 0)
{
await console.Output.WriteLineAsync($"Application URI: {serverArray[0]}");
}
}
catch
{
// Informational only
}
}
}