Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs
T
Joseph Doherty b13bdc154a test(ablegacy): deploy the PCCC fixture, one port per family (#534)
The AbLegacy integration suite has never run against a simulator.
/opt/otopcua-ablegacy/ did not exist on the Docker host — the stack was written
but never deployed — and every ab_server across BOTH ab fixtures bound :44818.
So the AB CIP fixture's ControlLogix container (up for two weeks) answered this
suite's TCP reachability probe, the suite declined to skip, and it ran SLC500-mode
PCCC against a ControlLogix. Four failures that read as "fixture missing".

Two structural problems, both fixed here:

- The theory parameterises over all three PCCC families, but only one container
  could ever exist on the shared port, so at least two cases failed on every
  conceivable run. The suite was un-greenable by construction, and
  AB_LEGACY_COMPOSE_PROFILE existed to paper over it by filtering the theory
  down to whichever container happened to be up.
- A reachability probe that cannot tell one simulator from another is not a skip
  gate. It answered "is some ab_server listening", which is a different question
  from the one the caller is asking, and reported the difference as a test
  failure.

Each family now has its own host port — 44819 slc500, 44820 micrologix, 44821
plc5 — so all three run together and alongside AbCip on 44818 (ab_server still
listens on 44818 inside each container; only the host mapping differs). Skip is
per-family via SkipReasonFor(profile); the class-level gate runs if ANY family is
up so one down container cannot silently skip the other two.
AB_LEGACY_COMPOSE_PROFILE now applies only in AB_LEGACY_ENDPOINT-pinned
real-hardware mode, where the family genuinely is a property of the one device.
This finally makes true what the fixture's XML doc has claimed from the start —
that it and the AB CIP fixture can point at different containers simultaneously.

Deployed to /opt/otopcua-ablegacy/ on 10.100.0.35; all three containers running.

The 4 tests still FAIL, and are left red on purpose — see #534. Now aimed at
correct-family simulators, the PCCC exchange itself returns BadCommunicationError
for every server-path / client-cip-path permutation, reproduced outside the test
harness via the driver CLI. That is a true signal about the driver (or about
libplctag's PCCC support against ab_server) that skipping would hide. What
changed here is that the failure is now diagnostic instead of an artifact of a
port collision.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 02:18:53 -04:00

265 lines
13 KiB
C#

using System.Net.Sockets;
using Xunit;
using Xunit.Sdk;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// Reachability probe for the <c>ab_server</c> Docker container running in a PCCC
/// plc mode (<c>SLC500</c> / <c>Micrologix</c> / <c>PLC/5</c>). Same container image
/// the AB CIP integration suite uses — libplctag's <c>ab_server</c> supports both
/// CIP + PCCC families from one binary. Tests skip via
/// <see cref="AbLegacyFactAttribute"/> / <see cref="AbLegacyTheoryAttribute"/> when
/// the port isn't live, so <c>dotnet test</c> stays green on a fresh clone without
/// Docker running.
/// </summary>
/// <remarks>
/// Env-var overrides:
/// <list type="bullet">
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of a single PCCC-mode device, pinning
/// <b>every</b> family to it (real-hardware mode, where the family is a property of the device
/// rather than of the port). Unset — the default — each family is reached on its own port on the
/// shared Docker host: <c>10.100.0.35:44819</c> slc500, <c>:44820</c> micrologix,
/// <c>:44821</c> plc5.</item>
/// <item><c>AB_LEGACY_CIP_PATH</c> — routing path appended to the <c>ab://host:port/</c>
/// URI. Defaults to <c>1,0</c> (port-1/slot-0 backplane), required by ab_server
/// which rejects unconnected Send_RR_Data with an empty path at the CIP layer
/// before the PCCC dispatcher runs. Real SLC 5/05 / MicroLogix / PLC-5 hardware
/// use an empty path — set <c>AB_LEGACY_CIP_PATH=</c> (empty) when pointing at
/// real hardware.</item>
/// </list>
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both can point at different
/// containers simultaneously during a combined test run — which was the stated intent from the start
/// but was not true in practice until 2026-08-01, because every ab_server across both fixtures
/// defaulted to <c>:44818</c>.
/// </remarks>
public sealed class AbLegacyServerFixture : IAsyncLifetime
{
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
private const string CipPathEnvVar = "AB_LEGACY_CIP_PATH";
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
/// with, not a different TCP listener.</summary>
public const int DefaultPort = 44818;
/// <summary>
/// ab_server rejects unconnected Send_RR_Data with an empty CIP routing path
/// at the CIP layer — the PCCC dispatcher never runs. <c>1,0</c> is the generic
/// port-1/slot-0 backplane path; any well-formed path passes the gate. Real
/// hardware (SLC 5/05 / MicroLogix / PLC-5) uses an empty path because there's
/// no backplane to cross, so point <c>AB_LEGACY_CIP_PATH=</c> (empty) at real
/// hardware to exercise the authentic wire semantics.
/// </summary>
public const string DefaultCipPath = "1,0";
// 10.100.0.35 = the shared Docker host (see CLAUDE.md "Docker Workflow"). Migrated
// off this VM's 127.0.0.1 on 2026-04-28 alongside the rest of the Docker-host move.
// Override via AB_LEGACY_ENDPOINT to point at a real PLC or a locally-running container.
/// <summary>Gets the server host address.</summary>
public string Host { get; } = "10.100.0.35";
/// <summary>
/// The port every family shares when <c>AB_LEGACY_ENDPOINT</c> pins one, or
/// <see cref="DefaultPort"/>. Prefer <see cref="PortFor"/> — against the Docker fixture each
/// family listens on its own port and this value addresses none of them.
/// </summary>
public int Port { get; } = DefaultPort;
/// <summary>True when <c>AB_LEGACY_ENDPOINT</c> pinned a single endpoint (real-hardware mode).</summary>
public bool EndpointPinned { get; }
/// <summary>
/// The port to reach <paramref name="profile"/>'s simulator on: the family's own port against the
/// Docker fixture, or the pinned endpoint's port when <c>AB_LEGACY_ENDPOINT</c> is set.
/// </summary>
/// <param name="profile">The family profile under test.</param>
/// <returns>The TCP port.</returns>
public int PortFor(AbLegacyServerProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
return EndpointPinned ? Port : profile.Port;
}
/// <summary>
/// Why <paramref name="profile"/>'s simulator cannot be reached, or null when it can.
/// </summary>
/// <remarks>
/// ⚠️ <b>Per-family, not per-suite (2026-08-01).</b> Every family used to share <c>:44818</c>, so
/// this could only ever answer "is some ab_server up" — and the AB CIP fixture's ControlLogix
/// container answers there too. The probe therefore reported reachable, the suite declined to
/// skip, and SLC500-mode PCCC ran against a ControlLogix and failed at the wire. A reachability
/// check that cannot tell one simulator from another is not a skip gate; it is a coin flip that
/// reports its result as a test failure.
/// </remarks>
/// <param name="profile">The family profile under test.</param>
/// <returns>A skip reason, or <see langword="null"/>.</returns>
public string? SkipReasonFor(AbLegacyServerProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
var port = PortFor(profile);
if (TcpProbe(Host, port)) return null;
return EndpointPinned
? $"AB Legacy PCCC endpoint at {Host}:{port} ({EndpointEnvVar}) not reachable within 2 s."
: $"AB Legacy {profile.ComposeProfile} simulator at {Host}:{port} not reachable within 2 s. "
+ $"Start it (docker compose -f Docker/docker-compose.yml --profile {profile.ComposeProfile} "
+ $"up -d), attach real hardware, or override {EndpointEnvVar}.";
}
/// <summary>CIP routing path portion of the device URI (after the <c>/</c> separator).
/// May be empty when targeting real hardware; non-empty against ab_server.</summary>
public string CipPath { get; } = DefaultCipPath;
/// <summary>Initializes the AB Legacy server fixture by reading environment overrides.</summary>
public AbLegacyServerFixture()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
{
var parts = raw.Split(':', 2);
Host = parts[0];
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
EndpointPinned = true;
}
// Empty override is intentional (real hardware); treat `null` as "not set, use
// default" but preserve an explicit empty-string override.
var cipOverride = Environment.GetEnvironmentVariable(CipPathEnvVar);
if (cipOverride is not null) CipPath = cipOverride;
}
/// <summary>Initializes the fixture asynchronously.</summary>
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
/// <summary>Disposes the fixture asynchronously.</summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Used by <see cref="AbLegacyFactAttribute"/> + <see cref="AbLegacyTheoryAttribute"/>
/// during test-class construction — gates whether the test runs at all. Duplicates the
/// fixture logic because attribute ctors fire before the collection fixture instance
/// exists.
/// </summary>
public static bool IsServerAvailable()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
if (raw is { Length: > 0 })
{
var parts = raw.Split(':', 2);
var pinned = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
return TcpProbe(parts[0], pinned);
}
// ANY family's simulator being up runs the class; each test then skips itself on its own port
// via SkipReasonFor. Gating the whole class on one family would skip the other two whenever it
// happened to be down, which reads as coverage that ran.
return KnownProfiles.All.Any(profile => TcpProbe("10.100.0.35", profile.Port));
}
private static bool TcpProbe(string host, int port)
{
try
{
using var client = new TcpClient();
var task = client.ConnectAsync(host, port);
return task.Wait(TimeSpan.FromSeconds(2)) && client.Connected;
}
catch { return false; }
}
}
/// <summary>
/// Per-family marker for the PCCC-mode compose profile a given test targets. The
/// compose file (<c>Docker/docker-compose.yml</c>) is the canonical source of truth
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
/// family enum to its compose-profile name + operator-facing notes.
/// </summary>
/// <param name="Family">The PCCC family.</param>
/// <param name="ComposeProfile">The compose profile name that starts this family's container.</param>
/// <param name="Port">
/// The family's own host port. Each family gets one so all three run together, and alongside the
/// AB CIP fixture on <c>:44818</c>. Overridden wholesale by <c>AB_LEGACY_ENDPOINT</c> (real hardware,
/// where there is one device and the family is a property of it, not of the port).
/// </param>
/// <param name="Notes">Operator-facing description of what the family's container seeds.</param>
public sealed record AbLegacyServerProfile(
AbLegacyPlcFamily Family,
string ComposeProfile,
int Port,
string Notes);
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
/// <remarks>
/// The <c>Port</c> values must match <c>Docker/docker-compose.yml</c>'s host-side mappings.
/// </remarks>
public static class KnownProfiles
{
public static readonly AbLegacyServerProfile Slc500 = new(
Family: AbLegacyPlcFamily.Slc500,
ComposeProfile: "slc500",
Port: 44819,
Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files.");
public static readonly AbLegacyServerProfile MicroLogix = new(
Family: AbLegacyPlcFamily.MicroLogix,
ComposeProfile: "micrologix",
Port: 44820,
Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included.");
public static readonly AbLegacyServerProfile Plc5 = new(
Family: AbLegacyPlcFamily.Plc5,
ComposeProfile: "plc5",
Port: 44821,
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
/// <summary>Gets all known profiles.</summary>
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
[Slc500, MicroLogix, Plc5];
/// <summary>Gets the profile for the specified PLC family.</summary>
/// <param name="family">The PLC family.</param>
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
All.FirstOrDefault(p => p.Family == family)
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
}
[Xunit.CollectionDefinition(Name)]
public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacyServerFixture>
{
public const string Name = "AbLegacyServer";
}
/// <summary>
/// <c>[Fact]</c>-equivalent that skips when the PCCC endpoint isn't reachable.
/// See <see cref="AbLegacyServerFixture"/> for the exact skip semantics.
/// </summary>
public sealed class AbLegacyFactAttribute : FactAttribute
{
/// <summary>Initializes the attribute, skipping the test if the server is unavailable.</summary>
public AbLegacyFactAttribute()
{
if (!AbLegacyServerFixture.IsServerAvailable())
Skip = "AB Legacy PCCC endpoint not reachable. Start the Docker fixture " +
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
"or point AB_LEGACY_ENDPOINT at real hardware.";
}
}
/// <summary>
/// <c>[Theory]</c>-equivalent with the same gate as <see cref="AbLegacyFactAttribute"/>.
/// </summary>
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
{
/// <summary>Initializes the attribute, skipping the test if the server is unavailable.</summary>
public AbLegacyTheoryAttribute()
{
if (!AbLegacyServerFixture.IsServerAvailable())
Skip = "AB Legacy PCCC endpoint not reachable. Start the Docker fixture " +
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
"or point AB_LEGACY_ENDPOINT at real hardware.";
}
}