96 lines
3.8 KiB
C#
96 lines
3.8 KiB
C#
using System.Net.Sockets;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Multi-endpoint fixture for upstream-redundancy smoke tests (PR-14, issue #286).
|
|
/// Probes both <c>opc-plc</c> instances from the docker-compose stack —
|
|
/// <c>opc-plc</c> on 50000 + <c>opc-plc-secondary</c> on 50002 — and exposes
|
|
/// a <see cref="SkipReason"/> when either is unreachable. Tests use the pair to
|
|
/// drive a ServiceLevel drop on the primary and assert the driver fails over
|
|
/// to the secondary mid-session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The primary endpoint URL can be overridden via <c>OPCUA_SIM_ENDPOINT</c> + the
|
|
/// secondary via <c>OPCUA_SIM_ENDPOINT_SECONDARY</c> for runs against real
|
|
/// redundant servers. Defaults assume the docker-compose stack is up locally
|
|
/// (<c>docker compose -f Docker/docker-compose.yml up opc-plc opc-plc-secondary</c>).
|
|
/// </remarks>
|
|
public sealed class OpcPlcRedundancyFixture : IAsyncDisposable
|
|
{
|
|
private const string DefaultPrimary = "opc.tcp://localhost:50000";
|
|
private const string DefaultSecondary = "opc.tcp://localhost:50002";
|
|
private const string PrimaryEnvVar = "OPCUA_SIM_ENDPOINT";
|
|
private const string SecondaryEnvVar = "OPCUA_SIM_ENDPOINT_SECONDARY";
|
|
|
|
public string PrimaryEndpointUrl { get; }
|
|
public string SecondaryEndpointUrl { get; }
|
|
public string? SkipReason { get; }
|
|
|
|
public OpcPlcRedundancyFixture()
|
|
{
|
|
PrimaryEndpointUrl = Environment.GetEnvironmentVariable(PrimaryEnvVar) ?? DefaultPrimary;
|
|
SecondaryEndpointUrl = Environment.GetEnvironmentVariable(SecondaryEnvVar) ?? DefaultSecondary;
|
|
|
|
if (!ProbeTcp(PrimaryEndpointUrl, out var primaryReason))
|
|
{
|
|
SkipReason = primaryReason;
|
|
return;
|
|
}
|
|
if (!ProbeTcp(SecondaryEndpointUrl, out var secondaryReason))
|
|
{
|
|
SkipReason = secondaryReason;
|
|
return;
|
|
}
|
|
}
|
|
|
|
private static bool ProbeTcp(string endpointUrl, out string? skipReason)
|
|
{
|
|
skipReason = null;
|
|
var (host, port) = ParseHostPort(endpointUrl);
|
|
try
|
|
{
|
|
using var client = new TcpClient(AddressFamily.InterNetwork);
|
|
var task = client.ConnectAsync(
|
|
System.Net.Dns.GetHostAddresses(host)
|
|
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
|
?? System.Net.IPAddress.Loopback,
|
|
port);
|
|
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
|
{
|
|
skipReason = $"opc-plc instance at {host}:{port} did not accept a TCP connection within 2s. " +
|
|
"Start it (`docker compose -f Docker/docker-compose.yml up opc-plc opc-plc-secondary`).";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
skipReason = $"opc-plc instance at {host}:{port} unreachable: {ex.GetType().Name}: {ex.Message}.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static (string Host, int Port) ParseHostPort(string endpointUrl)
|
|
{
|
|
const string scheme = "opc.tcp://";
|
|
var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)
|
|
? endpointUrl[scheme.Length..]
|
|
: endpointUrl;
|
|
var slash = body.IndexOf('/');
|
|
if (slash >= 0) body = body[..slash];
|
|
var colon = body.IndexOf(':');
|
|
if (colon < 0) return (body, 4840);
|
|
var host = body[..colon];
|
|
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840);
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
[Xunit.CollectionDefinition(Name)]
|
|
public sealed class OpcPlcRedundancyCollection : Xunit.ICollectionFixture<OpcPlcRedundancyFixture>
|
|
{
|
|
public const string Name = "OpcPlcRedundancy";
|
|
}
|