Auto: opcuaclient-11 — reverse connect (server-initiated)

Closes #283
This commit is contained in:
Joseph Doherty
2026-04-26 06:08:30 -04:00
parent 9a3bc08e1c
commit 5c72deb839
10 changed files with 920 additions and 26 deletions

View File

@@ -43,3 +43,41 @@ services:
timeout: 2s
retries: 10
start_period: 10s
# opc-plc-rc — reverse-connect (server-initiated) variant. The simulator
# acts as the OPC UA server but, unlike the regular service above, it dials
# OUT to the client's listener URL instead of accepting an inbound dial.
# Mirrors the OT-DMZ topology where the plant firewall only permits
# outbound traffic from the upstream server. The driver-side test fixture
# binds opc.tcp://0.0.0.0:4844 and waits for opc-plc-rc to ReverseHello.
#
# `--rc` is opc-plc's reverse-connect knob — value is the client URL the
# simulator should dial when it has no inbound connection. host.docker.internal
# is the docker-for-windows / docker-for-mac shorthand for the host's IP;
# on Linux hosts use --add-host=host.docker.internal:host-gateway.
opc-plc-rc:
image: mcr.microsoft.com/iotedge/opc-plc:2.14.10
container_name: otopcua-opc-plc-rc
restart: "no"
extra_hosts:
- "host.docker.internal:host-gateway"
command:
# --pn=50001: bind on a different port so this container can run alongside
# the dial-mode simulator above. Reverse-connect doesn't require
# the client to know this port (the simulator is the dialer)
# but it still has to bind one for any incoming admin queries.
# --rc: reverse-connect target — the simulator dials this URL and
# presents its OPC UA endpoint over the inbound socket. Must
# point at the test runner's listener.
# --ut/--aa/--alm: same flags as the regular profile.
- "--pn=50001"
- "--rc=opc.tcp://host.docker.internal:4844"
- "--ut"
- "--aa"
- "--alm"
healthcheck:
test: ["CMD-SHELL", "netstat -an | grep -q ':50001.*LISTEN' || exit 1"]
interval: 5s
timeout: 2s
retries: 10
start_period: 10s

View File

@@ -0,0 +1,56 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Fixture for the reverse-connect variant of opc-plc (PR-11). Unlike the
/// dial-mode <see cref="OpcPlcFixture"/>, the simulator here is the dialer:
/// it reaches OUT to the test runner's listener URL on
/// <c>opc.tcp://host.docker.internal:4844</c>. The fixture's job is to
/// advertise the listener URL the test should bind and provide a clear
/// skip reason when the docker-compose service isn't running.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why no port-probe</b>: the conventional fixture probes the simulator's
/// server port to detect docker-up. In reverse-connect the simulator opens
/// no inbound port — it's a pure dialer — so a probe would always fail.
/// Tests that want a hard skip should look at <see cref="SkipReason"/>
/// which is set from the <c>OPCUA_RC_SIM</c> env var (any value =
/// "simulator running"; absent = skip).
/// </para>
/// <para>
/// The "shared listener URL" model is enforced by the driver's
/// <c>ReverseConnectListener</c> singleton — multiple smoke tests in the
/// same xunit assembly share one listener instance even if they run in
/// parallel. Tests should pick distinct <c>ExpectedServerUri</c> values to
/// demultiplex inbound connections.
/// </para>
/// </remarks>
public sealed class OpcPlcReverseConnectFixture : IAsyncDisposable
{
private const string DefaultListenerUrl = "opc.tcp://0.0.0.0:4844";
private const string EnvVar = "OPCUA_RC_SIM";
/// <summary>The listener URL the driver should bind for incoming reverse-connect dials.</summary>
public string ListenerUrl { get; } = DefaultListenerUrl;
/// <summary>Skip reason when the reverse-connect simulator isn't available; null when ready.</summary>
public string? SkipReason { get; }
public OpcPlcReverseConnectFixture()
{
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(EnvVar)))
{
SkipReason =
$"Reverse-connect smoke skipped — set {EnvVar}=1 once `docker compose -f Docker/docker-compose.yml up opc-plc-rc` is healthy. " +
"The dialer needs host.docker.internal to reach this machine — verify Docker Desktop's network mode supports it.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class OpcPlcReverseConnectCollection : Xunit.ICollectionFixture<OpcPlcReverseConnectFixture>
{
public const string Name = "OpcPlcReverseConnect";
}

View File

@@ -0,0 +1,65 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Reverse-connect smoke (PR-11). Asserts the driver binds a listener at the
/// configured URL and accepts an inbound dial from <c>opc-plc-rc</c> (the
/// reverse-connect variant of Microsoft Industrial IoT's OPC UA simulator).
/// The session that comes up should be functionally identical to a dialled
/// session — same Read / Subscribe surface — but the transport direction is
/// server → client instead of client → server.
/// </summary>
/// <remarks>
/// <para>
/// <b>Build-only by default</b>: the test is gated on <c>OPCUA_RC_SIM</c>
/// + the docker-compose <c>opc-plc-rc</c> service. CI runs that don't
/// spin up the dialer skip with a clear message; the build still has to
/// compile so wire-level regressions in the reverse-connect code path are
/// caught even when the dialer isn't around.
/// </para>
/// </remarks>
[Collection(OpcPlcReverseConnectCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "opc-plc-rc")]
public sealed class OpcUaClientReverseConnectSmokeTests(OpcPlcReverseConnectFixture rc)
{
[Fact]
public async Task Driver_accepts_reverse_connect_from_opc_plc_rc_simulator()
{
if (rc.SkipReason is not null) Assert.Skip(rc.SkipReason);
var options = new OpcUaClientDriverOptions
{
// Conventional EndpointUrl still required — the driver derives the
// EndpointDescription from it for the session-create call. The actual
// dial direction is flipped by ReverseConnect.Enabled below.
EndpointUrl = "opc.tcp://opc-plc-rc:50001",
SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None,
AuthType = OpcUaAuthType.Anonymous,
AutoAcceptCertificates = true,
Timeout = TimeSpan.FromSeconds(15),
SessionTimeout = TimeSpan.FromSeconds(60),
ReverseConnect = new ReverseConnectOptions(
Enabled: true,
ListenerUrl: rc.ListenerUrl,
// null = accept any upstream — only one is dialling this listener in
// the smoke test, so there's no demux to worry about.
ExpectedServerUri: null),
};
await using var drv = new OpcUaClientDriver(options, "opcua-rc-smoke");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// The session is up via reverse path — assert a steady-state read works.
var snapshots = await drv.ReadAsync(
[OpcPlcProfile.StepUp], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(1);
snapshots[0].StatusCode.ShouldBe(0u,
"reverse-connect session must round-trip a Read identically to a dialled session");
}
}