@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user