Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
32dff7f1d6 ab_server integration fixture — per-family profiles + documented CI-fetch contract. Closes task #180 (AB CIP follow-up — ab_server CI fixture). Replaces the prior hardcoded single-family fixture with a parametric AbServerProfile abstraction covering ControlLogix / CompactLogix / Micro800 / GuardLogix. Prebuilt-Windows-binary fetch is documented as a CI YAML step rather than fabricated C#-side, because SHA-pinned binary distribution is a CI workflow concern (libplctag owns releases, we pin a version + verify hash) not a test-framework concern. New AbServerProfile record + KnownProfiles static class at tests/.../AbServerProfile.cs. Four profiles: ControlLogix (widest coverage — DINT/REAL/BOOL/SINT/STRING atomic + DINT[16] array so the driver's @tags Symbol-Object decoder + array-bound path both get end-to-end coverage), CompactLogix (atomic subset — driver-side ConnectionSize quirk from PR 10 still applies since ab_server doesn't enforce the narrower limit), Micro800 (ab_server has no dedicated --plc micro800 mode — falls back to controllogix while driver-side path enforces empty routing + unconnected-only per PR 11; real Micro800 coverage requires a 2080 lab rig), GuardLogix (ab_server has no safety subsystem — profile emulates the _S-suffixed naming contract the driver's safety-ViewOnly classification reads in PR 12; real safety-lock behavior requires a 1756-L8xS physical rig). Each profile composes --plc + --tag args via BuildCliArgs(port) — pure string formatter so the composition logic is unit-testable without launching the simulator. AbServerFixture gains a ctor overload taking AbServerProfile + port (defaults back to ControlLogix on parameterless ctor so existing test suites keep compiling). Fixture's InitializeAsync hands the profile's CLI args to ProcessStartInfo.Arguments. New AbServerTheoryAttribute mirrors AbServerFactAttribute but extends TheoryAttribute so a single test can MemberData over KnownProfiles.All + cover all four families. AbCipReadSmokeTests converted from single-fact to theory parametrized over KnownProfiles.All — one row per family reads TestDINT + asserts Good status + Healthy driver state. Fixture lifecycle is explicit try/finally rather than await using because IAsyncLifetime.DisposeAsync returns ValueTask + xUnit's concrete IAsyncDisposable shim depends on xunit version; explicit beats implicit here. Eight new unit tests in AbServerProfileTests.cs (runs without the simulator so CI green even when the binary is absent): BuildCliArgs composes port + plc + tag flags in the documented order; empty seed-tag list still emits port + plc; SeedTag.ToCliSpec handles both 2-segment scalar + 3-segment array; KnownProfiles.ForFamily returns expected --plc arg for every family (verifies Micro800 + GuardLogix both fall back to controllogix); KnownProfiles.All covers every AbCipPlcFamily enum value (regression guard — adding a new family without a profile fails this test); ControlLogix seeds every atomic type the driver supports; GuardLogix seeds at least one _S-suffixed safety tag. Integration tests still skip cleanly when ab_server isn't on PATH. 11/11 unit tests passing in this project (8 new + 3 prior). Full Admin solution builds 0 errors. docs/v2/test-data-sources.md gets a new "CI fixture" subsection under §2.Gotchas with the exact GitHub Actions YAML step — fetch the pinned libplctag release, SHA256-verify against a pinned hash recorded in the repo's CI lockfile (drift = fail closed), extract, append to PATH. The C# harness stays PATH-driven so dev-box installs (cmake + make from source) work identically to CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:57:24 -04:00
42649ca7b0 Merge pull request (#141) - Redundancy OTel + SignalR 2026-04-19 23:18:04 -04:00
Joseph Doherty
1f3343e61f OpenTelemetry redundancy metrics + RoleChanged SignalR push. Closes instrumentation + live-push slices of task #198; the exporter wiring (OTLP vs Prometheus package decision) is split to new task #201 because the collector/scrape-endpoint choice is a fleet-ops decision that deserves its own PR rather than hardcoded here. New RedundancyMetrics class (Singleton-registered in DI) owning a System.Diagnostics.Metrics.Meter("ZB.MOM.WW.OtOpcUa.Redundancy", "1.0.0"). Three ObservableGauge instruments — otopcua.redundancy.primary_count / secondary_count / stale_count — all tagged by cluster.id, populated by SetClusterCounts(clusterId, primary, secondary, stale) which the poller calls at the tail of every tick; ObservableGauge callbacks snapshot the last value set under a lock so the reader (OTel collector, dotnet-counters) sees consistent tuples. One Counter — otopcua.redundancy.role_transition — tagged cluster.id, node.id, from_role, to_role; ideal for tracking "how often does Cluster-X failover" + "which node transitions most" aggregate queries. In-box Metrics API means zero NuGet dep here — the exporter PR adds OpenTelemetry.Extensions.Hosting + OpenTelemetry.Exporter.OpenTelemetryProtocol or OpenTelemetry.Exporter.Prometheus.AspNetCore to actually ship the data somewhere. FleetStatusPoller extended with role-change detection. Its PollOnceAsync now pulls ClusterNode rows alongside the existing ClusterNodeGenerationState scan, and a new PollRolesAsync walks every node comparing RedundancyRole to the _lastRole cache. On change: records the transition to RedundancyMetrics + emits a RoleChanged SignalR message to both FleetStatusHub.GroupName(cluster) + FleetStatusHub.FleetGroup so cluster-scoped + fleet-wide subscribers both see it. First observation per node is a bootstrap (cache fill) + NOT a transition — avoids spurious churn on service startup or pod restart. UpdateClusterGauges groups nodes by cluster + sets the three gauge values, using ClusterNodeService.StaleThreshold (shared 30s convention) for staleness so the /hosts page + the gauge agree. RoleChangedMessage record lives alongside NodeStateChangedMessage in FleetStatusPoller.cs. RedundancyTab.razor subscribes to the fleet-status hub on first parameters-set, filters RoleChanged events to the current cluster, reloads the node list + paints a blue info banner ("Role changed on node-a: Primary → Secondary at HH:mm:ss UTC") so operators see the transition without needing to poll-refresh the page. IAsyncDisposable closes the connection on tab swap-away. Two new RedundancyMetricsTests covering RecordRoleTransition tag emission (cluster.id + node.id + from_role + to_role all flow through the MeterListener callback) + ObservableGauge snapshot for two clusters (assert primary_count=1 for c1, stale_count=1 for c2). Existing FleetStatusPollerTests ctor-line updated to pass a RedundancyMetrics instance; all tests still pass. Full Admin.Tests suite 87/87 passing (was 85, +2). Admin project builds 0 errors. Task #201 captures the exporter-wiring follow-up — OpenTelemetry.Extensions.Hosting + OTLP vs Prometheus + /metrics endpoint decision, driven by fleet-ops infra direction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:16:09 -04:00
251f567b98 Merge pull request (#140) - AlarmSurfaceInvoker 2026-04-19 23:09:35 -04:00
11 changed files with 604 additions and 40 deletions

View File

@@ -189,6 +189,35 @@ Modbus has no native String, DateTime, or Int64 — those rows are skipped on th
- **ab_server tag-type coverage is finite** (BOOL, DINT, REAL, arrays, basic strings). UDTs and `Program:` scoping are not fully implemented. Document an "ab_server-supported tag set" in the harness and exclude the rest from default CI; UDT coverage moves to the Studio 5000 Emulate golden-box tier.
- CIP has no native subscriptions, so polling behavior matches real hardware.
### CI fixture (task #180)
The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` exposes two test-time contracts:
- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture<AbServerFixture>` wrapper per family.
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them — the driver-side family profile still enforces the narrower connection shape / safety classification separately.
**CI step (intended — fleet-ops to wire):**
```yaml
# GitHub Actions step placed before `dotnet test`:
- name: Fetch ab_server
shell: pwsh
run: |
$ver = '<pinned libplctag release tag>'
$url = "https://github.com/libplctag/libplctag/releases/download/$ver/ab_server-windows-x64.zip"
Invoke-WebRequest $url -OutFile $env:RUNNER_TEMP/ab_server.zip
# SHA256 check against a pinned value recorded in this repo's CI lockfile — drift = fail closed
$expected = '<pinned sha256>'
$actual = (Get-FileHash -Algorithm SHA256 $env:RUNNER_TEMP/ab_server.zip).Hash
if ($expected -ne $actual) { throw "ab_server hash mismatch" }
Expand-Archive $env:RUNNER_TEMP/ab_server.zip -DestinationPath $env:RUNNER_TEMP/ab_server
echo "$env:RUNNER_TEMP/ab_server" >> $env:GITHUB_PATH
```
The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically.
Tests without ab_server on PATH are marked `Skip` via `AbServerFactAttribute` / `AbServerTheoryAttribute`, so fresh-clone runs without the simulator still pass all unit suites in this project.
---
## 3. Allen-Bradley Legacy (SLC 500 / MicroLogix, PCCC)

View File

@@ -1,9 +1,17 @@
@using Microsoft.AspNetCore.SignalR.Client
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
@using ZB.MOM.WW.OtOpcUa.Admin.Services
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject ClusterNodeService NodeSvc
@inject NavigationManager Nav
@implements IAsyncDisposable
<h4>Redundancy topology</h4>
@if (_roleChangedBanner is not null)
{
<div class="alert alert-info small mb-2">@_roleChangedBanner</div>
}
<p class="text-muted small">
One row per <code>ClusterNode</code> in this cluster. Role, <code>ApplicationUri</code>,
and <code>ServiceLevelBase</code> are authored separately; the Admin UI shows them read-only
@@ -107,10 +115,41 @@ else
[Parameter] public string ClusterId { get; set; } = string.Empty;
private List<ClusterNode>? _nodes;
private HubConnection? _hub;
private string? _roleChangedBanner;
protected override async Task OnParametersSetAsync()
{
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
if (_hub is null) await ConnectHubAsync();
}
private async Task ConnectHubAsync()
{
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet-status"))
.WithAutomaticReconnect()
.Build();
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
{
if (msg.ClusterId != ClusterId) return;
_roleChangedBanner = $"Role changed on {msg.NodeId}: {msg.FromRole} → {msg.ToRole} at {msg.ObservedAtUtc:HH:mm:ss 'UTC'}";
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
await InvokeAsync(StateHasChanged);
});
await _hub.StartAsync();
await _hub.SendAsync("SubscribeCluster", ClusterId);
}
public async ValueTask DisposeAsync()
{
if (_hub is not null)
{
await _hub.DisposeAsync();
_hub = null;
}
}
private static string RowClass(ClusterNode n) =>

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
@@ -14,11 +16,13 @@ public sealed class FleetStatusPoller(
IServiceScopeFactory scopeFactory,
IHubContext<FleetStatusHub> fleetHub,
IHubContext<AlertHub> alertHub,
ILogger<FleetStatusPoller> logger) : BackgroundService
ILogger<FleetStatusPoller> logger,
RedundancyMetrics redundancyMetrics) : BackgroundService
{
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
private readonly Dictionary<string, NodeStateSnapshot> _last = new();
private readonly Dictionary<string, RedundancyRole> _lastRole = new(StringComparer.Ordinal);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -42,6 +46,10 @@ public sealed class FleetStatusPoller(
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var nodes = await db.ClusterNodes.AsNoTracking().ToListAsync(ct);
await PollRolesAsync(nodes, ct);
UpdateClusterGauges(nodes);
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n.ClusterId })
.ToListAsync(ct);
@@ -85,9 +93,63 @@ public sealed class FleetStatusPoller(
}
/// <summary>Exposed for tests — forces a snapshot reset so stub data re-seeds.</summary>
internal void ResetCache() => _last.Clear();
internal void ResetCache()
{
_last.Clear();
_lastRole.Clear();
}
private async Task PollRolesAsync(IReadOnlyList<ClusterNode> nodes, CancellationToken ct)
{
foreach (var n in nodes)
{
var hadPrior = _lastRole.TryGetValue(n.NodeId, out var priorRole);
if (hadPrior && priorRole == n.RedundancyRole) continue;
_lastRole[n.NodeId] = n.RedundancyRole;
if (!hadPrior) continue; // first-observation bootstrap — not a transition
redundancyMetrics.RecordRoleTransition(
clusterId: n.ClusterId, nodeId: n.NodeId,
fromRole: priorRole.ToString(), toRole: n.RedundancyRole.ToString());
var msg = new RoleChangedMessage(
ClusterId: n.ClusterId, NodeId: n.NodeId,
FromRole: priorRole.ToString(), ToRole: n.RedundancyRole.ToString(),
ObservedAtUtc: DateTime.UtcNow);
await fleetHub.Clients.Group(FleetStatusHub.GroupName(n.ClusterId))
.SendAsync("RoleChanged", msg, ct);
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
.SendAsync("RoleChanged", msg, ct);
}
}
private void UpdateClusterGauges(IReadOnlyList<ClusterNode> nodes)
{
var staleCutoff = DateTime.UtcNow - Services.ClusterNodeService.StaleThreshold;
foreach (var group in nodes.GroupBy(n => n.ClusterId))
{
var primary = group.Count(n => n.RedundancyRole == RedundancyRole.Primary);
var secondary = group.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
var stale = group.Count(n => n.LastSeenAt is null || n.LastSeenAt.Value < staleCutoff);
redundancyMetrics.SetClusterCounts(group.Key, primary, secondary, stale);
}
}
private readonly record struct NodeStateSnapshot(
string NodeId, string ClusterId, long? GenerationId,
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}
/// <summary>
/// Pushed by <see cref="FleetStatusPoller"/> when it observes a change in
/// <see cref="ClusterNode.RedundancyRole"/>. Consumed by the Admin RedundancyTab to trigger
/// an instant reload instead of waiting for the next on-parameter-set poll.
/// </summary>
public sealed record RoleChangedMessage(
string ClusterId,
string NodeId,
string FromRole,
string ToRole,
DateTime ObservedAtUtc);

View File

@@ -49,6 +49,7 @@ builder.Services.AddScoped<DraftValidationService>();
builder.Services.AddScoped<AuditLogService>();
builder.Services.AddScoped<HostStatusService>();
builder.Services.AddScoped<ClusterNodeService>();
builder.Services.AddSingleton<RedundancyMetrics>();
builder.Services.AddScoped<EquipmentImportBatchService>();
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();

View File

@@ -0,0 +1,102 @@
using System.Diagnostics.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// OpenTelemetry-compatible instrumentation for the redundancy surface. Uses in-box
/// <see cref="System.Diagnostics.Metrics"/> so no NuGet dependency is required to emit —
/// any MeterListener (dotnet-counters, OpenTelemetry.Extensions.Hosting OTLP exporter,
/// Prometheus exporter, etc.) picks up the instruments by the <see cref="MeterName"/>.
/// </summary>
/// <remarks>
/// Exporter configuration (OTLP, Prometheus, etc.) is intentionally NOT wired here —
/// that's a deployment-ops decision that belongs in <c>Program.cs</c> behind an
/// <c>appsettings</c> toggle. This class owns only the Meter + instruments so the
/// production data stream exists regardless of exporter availability.
///
/// Counter + gauge names follow the otel-semantic-conventions pattern:
/// <c>otopcua.redundancy.*</c> with tags for ClusterId + (for transitions) FromRole/ToRole/NodeId.
/// </remarks>
public sealed class RedundancyMetrics : IDisposable
{
public const string MeterName = "ZB.MOM.WW.OtOpcUa.Redundancy";
private readonly Meter _meter;
private readonly Counter<long> _roleTransitions;
private readonly object _gaugeLock = new();
private readonly Dictionary<string, ClusterGaugeState> _gaugeState = new();
public RedundancyMetrics()
{
_meter = new Meter(MeterName, version: "1.0.0");
_roleTransitions = _meter.CreateCounter<long>(
"otopcua.redundancy.role_transition",
unit: "{transition}",
description: "Observed RedundancyRole changes per node — tagged FromRole, ToRole, NodeId, ClusterId.");
// Observable gauges — the callback reports whatever the last Observe*Count call stashed.
_meter.CreateObservableGauge(
"otopcua.redundancy.primary_count",
ObservePrimaryCounts,
unit: "{node}",
description: "Count of Primary-role nodes per cluster (should be 1 for N+1 redundant clusters, 0 during failover).");
_meter.CreateObservableGauge(
"otopcua.redundancy.secondary_count",
ObserveSecondaryCounts,
unit: "{node}",
description: "Count of Secondary-role nodes per cluster.");
_meter.CreateObservableGauge(
"otopcua.redundancy.stale_count",
ObserveStaleCounts,
unit: "{node}",
description: "Count of cluster nodes whose LastSeenAt is older than StaleThreshold.");
}
/// <summary>
/// Update the per-cluster snapshot consumed by the ObservableGauges. Poller calls this
/// at the end of every tick so the collectors see fresh numbers on the next observation
/// window (by default 1s for dotnet-counters, configurable per exporter).
/// </summary>
public void SetClusterCounts(string clusterId, int primary, int secondary, int stale)
{
lock (_gaugeLock)
{
_gaugeState[clusterId] = new ClusterGaugeState(primary, secondary, stale);
}
}
/// <summary>
/// Increment the role_transition counter when a node's RedundancyRole changes. Tags
/// allow breakdowns by from/to roles (e.g. Primary → Secondary for planned failover vs
/// Primary → Standalone for emergency recovery) + by cluster for multi-site fleets.
/// </summary>
public void RecordRoleTransition(string clusterId, string nodeId, string fromRole, string toRole)
{
_roleTransitions.Add(1,
new KeyValuePair<string, object?>("cluster.id", clusterId),
new KeyValuePair<string, object?>("node.id", nodeId),
new KeyValuePair<string, object?>("from_role", fromRole),
new KeyValuePair<string, object?>("to_role", toRole));
}
public void Dispose() => _meter.Dispose();
private IEnumerable<Measurement<long>> ObservePrimaryCounts() => SnapshotGauge(s => s.Primary);
private IEnumerable<Measurement<long>> ObserveSecondaryCounts() => SnapshotGauge(s => s.Secondary);
private IEnumerable<Measurement<long>> ObserveStaleCounts() => SnapshotGauge(s => s.Stale);
private IEnumerable<Measurement<long>> SnapshotGauge(Func<ClusterGaugeState, int> selector)
{
List<Measurement<long>> results;
lock (_gaugeLock)
{
results = new List<Measurement<long>>(_gaugeState.Count);
foreach (var (cluster, state) in _gaugeState)
results.Add(new Measurement<long>(selector(state),
new KeyValuePair<string, object?>("cluster.id", cluster)));
}
return results;
}
private readonly record struct ClusterGaugeState(int Primary, int Secondary, int Stale);
}

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -97,7 +98,7 @@ END";
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance);
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
await poller.PollOnceAsync(CancellationToken.None);
@@ -142,7 +143,7 @@ END";
var poller = new FleetStatusPoller(
_sp.GetRequiredService<IServiceScopeFactory>(),
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance);
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
await poller.PollOnceAsync(CancellationToken.None);

View File

@@ -0,0 +1,70 @@
using System.Diagnostics.Metrics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class RedundancyMetricsTests
{
[Fact]
public void RecordRoleTransition_Increments_Counter_WithExpectedTags()
{
using var metrics = new RedundancyMetrics();
using var listener = new MeterListener();
var observed = new List<(long Value, Dictionary<string, object?> Tags)>();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == RedundancyMetrics.MeterName &&
instrument.Name == "otopcua.redundancy.role_transition")
{
l.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
var dict = new Dictionary<string, object?>();
foreach (var tag in tags) dict[tag.Key] = tag.Value;
observed.Add((value, dict));
});
listener.Start();
metrics.RecordRoleTransition("c1", "node-a", "Primary", "Secondary");
observed.Count.ShouldBe(1);
observed[0].Value.ShouldBe(1);
observed[0].Tags["cluster.id"].ShouldBe("c1");
observed[0].Tags["node.id"].ShouldBe("node-a");
observed[0].Tags["from_role"].ShouldBe("Primary");
observed[0].Tags["to_role"].ShouldBe("Secondary");
}
[Fact]
public void SetClusterCounts_Observed_Via_ObservableGauges()
{
using var metrics = new RedundancyMetrics();
metrics.SetClusterCounts("c1", primary: 1, secondary: 2, stale: 0);
metrics.SetClusterCounts("c2", primary: 0, secondary: 1, stale: 1);
var observations = new List<(string Name, long Value, string Cluster)>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instrument, l) =>
{
if (instrument.Meter.Name == RedundancyMetrics.MeterName)
l.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
{
string? cluster = null;
foreach (var t in tags) if (t.Key == "cluster.id") cluster = t.Value as string;
observations.Add((instrument.Name, value, cluster ?? "?"));
});
listener.Start();
listener.RecordObservableInstruments();
observations.ShouldContain(o => o.Name == "otopcua.redundancy.primary_count" && o.Cluster == "c1" && o.Value == 1);
observations.ShouldContain(o => o.Name == "otopcua.redundancy.secondary_count" && o.Cluster == "c1" && o.Value == 2);
observations.ShouldContain(o => o.Name == "otopcua.redundancy.stale_count" && o.Cluster == "c2" && o.Value == 1);
}
}

View File

@@ -8,37 +8,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// End-to-end smoke tests that exercise the real libplctag stack against a running
/// <c>ab_server</c>. Skipped when the binary isn't on PATH (<see cref="AbServerFactAttribute"/>).
/// Parametrized over <see cref="KnownProfiles.All"/> so one test file covers every family
/// (ControlLogix / CompactLogix / Micro800 / GuardLogix).
/// </summary>
/// <remarks>
/// Intentionally minimal — per-family + per-capability coverage ships in PRs 912 once the
/// integration harness is CI-ready. This file exists at PR 3 time to prove the wire path
/// works end-to-end on developer boxes that have <c>ab_server</c>.
/// </remarks>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class AbCipReadSmokeTests : IAsyncLifetime
public sealed class AbCipReadSmokeTests
{
private readonly AbServerFixture _fixture = new();
public static IEnumerable<object[]> Profiles =>
KnownProfiles.All.Select(p => new object[] { p });
public async ValueTask InitializeAsync() => await _fixture.InitializeAsync();
public async ValueTask DisposeAsync() => await _fixture.DisposeAsync();
[AbServerFact]
public async Task Driver_reads_DInt_from_ab_server()
[AbServerTheory]
[MemberData(nameof(Profiles))]
public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile)
{
var drv = new AbCipDriver(new AbCipDriverOptions
var fixture = new AbServerFixture(profile);
await fixture.InitializeAsync();
try
{
Devices = [new AbCipDeviceOptions($"ab://127.0.0.1:{_fixture.Port}/1,0", AbCipPlcFamily.ControlLogix)],
Tags = [new AbCipTagDefinition("Counter", $"ab://127.0.0.1:{_fixture.Port}/1,0", "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, "drv-smoke");
var deviceUri = $"ab://127.0.0.1:{fixture.Port}/1,0";
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)],
Tags = [new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)],
Timeout = TimeSpan.FromSeconds(5),
}, $"drv-smoke-{profile.Family}");
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.ShutdownAsync(CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
finally
{
await fixture.DisposeAsync();
}
}
}

View File

@@ -6,28 +6,44 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
/// the duration of an integration test collection. Binary is expected on PATH; the per-test
/// JSON profile is passed via <c>--config</c>.
/// the duration of an integration test collection. The fixture takes an
/// <see cref="AbServerProfile"/> (see <see cref="KnownProfiles"/>) so each AB family — ControlLogix,
/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right <c>--plc</c>
/// mode + preseed tag set. Binary is expected on PATH; CI resolves that via a job step
/// that downloads the pinned Windows build from libplctag GitHub Releases before
/// <c>dotnet test</c> — see <c>docs/v2/test-data-sources.md §2.CI</c> for the exact step.
/// </summary>
/// <remarks>
/// <para><c>ab_server</c> is a C binary shipped in the same repo as libplctag (see
/// <c>test-data-sources.md</c> §2 and plan decision #99). On a developer workstation it's
/// built once from source and placed on PATH; in CI we intend to publish a prebuilt Windows
/// x64 binary as a GitHub release asset in a follow-up PR so the fixture can download +
/// extract it at setup time. Until then every test in this project is skipped when
/// <c>ab_server</c> is not locatable.</para>
/// <para><c>ab_server</c> is a C binary shipped in libplctag's repo (MIT). On developer
/// workstations it's built once from source and placed on PATH; on CI the workflow file
/// fetches a version-pinned prebuilt + stages it. Tests skip (via
/// <see cref="AbServerFactAttribute"/>) when the binary is not on PATH so a fresh clone
/// without the simulator still gets a green unit-test run.</para>
///
/// <para>Per-family JSON profiles (ControlLogix / CompactLogix / Micro800 / GuardLogix)
/// ship under <c>Profiles/</c> and drive the simulator's tag shape — this is where the
/// UDT + Program-scope coverage gap will be filled by the hand-rolled stub in PR 6.</para>
/// <para>Per-family profiles live in <see cref="KnownProfiles"/>. When a test wants a
/// specific family, instantiate the fixture with that profile — either via a
/// <see cref="IClassFixture{TFixture}"/> derived type or by constructing directly in a
/// parametric test (the latter is used below for the smoke suite).</para>
/// </remarks>
public sealed class AbServerFixture : IAsyncLifetime
{
private Process? _proc;
public int Port { get; } = 44818;
/// <summary>The profile the simulator was started with. Same instance the driver-side options should use.</summary>
public AbServerProfile Profile { get; }
public int Port { get; }
public bool IsAvailable { get; private set; }
public AbServerFixture() : this(KnownProfiles.ControlLogix, AbServerProfile.DefaultPort) { }
public AbServerFixture(AbServerProfile profile) : this(profile, AbServerProfile.DefaultPort) { }
public AbServerFixture(AbServerProfile profile, int port)
{
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
Port = port;
}
public ValueTask InitializeAsync() => InitializeAsync(default);
public ValueTask DisposeAsync() => DisposeAsync(default);
@@ -45,7 +61,7 @@ public sealed class AbServerFixture : IAsyncLifetime
StartInfo = new ProcessStartInfo
{
FileName = binary,
Arguments = $"--port {Port} --plc controllogix",
Arguments = Profile.BuildCliArgs(Port),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
@@ -75,7 +91,7 @@ public sealed class AbServerFixture : IAsyncLifetime
/// <summary>
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
/// depend on it should use <see cref="AbServerFact"/> so CI runs without the binary
/// depend on it should use <see cref="AbServerFactAttribute"/> so CI runs without the binary
/// simply skip rather than fail.
/// </summary>
public static string? LocateBinary()
@@ -107,3 +123,17 @@ public sealed class AbServerFactAttribute : FactAttribute
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
}
}
/// <summary>
/// <c>[Theory]</c>-equivalent that skips when <c>ab_server</c> is not on PATH. Pair with
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory row per
/// profile so a single test covers all four families.
/// </summary>
public sealed class AbServerTheoryAttribute : TheoryAttribute
{
public AbServerTheoryAttribute()
{
if (AbServerFixture.LocateBinary() is null)
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
}
}

View File

@@ -0,0 +1,134 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Per-family provisioning profile for the <c>ab_server</c> simulator. Instead of hard-coding
/// one fixture shape + one set of CLI args, each integration test picks a profile matching the
/// family it wants to exercise — ControlLogix / CompactLogix / Micro800 / GuardLogix. The
/// profile composes the CLI arg list passed to <c>ab_server</c> + the tag-definition set the
/// driver uses to address the simulator's pre-provisioned tags.
/// </summary>
/// <param name="Family">OtOpcUa driver family this profile targets. Drives
/// <see cref="AbCipDeviceOptions.PlcFamily"/> + driver-side connection-parameter profile
/// (ConnectionSize, unconnected-only, etc.) per decision #9.</param>
/// <param name="AbServerPlcArg">The value passed to <c>ab_server --plc &lt;arg&gt;</c>. Some families
/// map 1:1 (ControlLogix → "controllogix"); Micro800/GuardLogix fall back to the family whose
/// CIP behavior ab_server emulates most faithfully (see per-profile Notes).</param>
/// <param name="SeedTags">Tags to preseed on the simulator via <c>--tag &lt;name&gt;:&lt;type&gt;[:&lt;size&gt;]</c>
/// flags. Each entry becomes one CLI arg; the driver-side <see cref="AbCipTagDefinition"/>
/// list references the same names so tests can read/write without walking the @tags surface
/// first.</param>
/// <param name="Notes">Operator-facing description of what the profile covers + any quirks.</param>
public sealed record AbServerProfile(
AbCipPlcFamily Family,
string AbServerPlcArg,
IReadOnlyList<AbServerSeedTag> SeedTags,
string Notes)
{
/// <summary>Default port — every profile uses the same so parallel-runs-of-different-families
/// would conflict (deliberately — one simulator per test collection is the model).</summary>
public const int DefaultPort = 44818;
/// <summary>Compose the full <c>ab_server</c> CLI arg string for
/// <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>.</summary>
public string BuildCliArgs(int port)
{
var parts = new List<string>
{
"--port", port.ToString(),
"--plc", AbServerPlcArg,
};
foreach (var tag in SeedTags)
{
parts.Add("--tag");
parts.Add(tag.ToCliSpec());
}
return string.Join(' ', parts);
}
}
/// <summary>One tag the simulator pre-creates. ab_server spec format:
/// <c>&lt;name&gt;:&lt;type&gt;[:&lt;array_size&gt;]</c>.</summary>
public sealed record AbServerSeedTag(string Name, string AbServerType, int? ArraySize = null)
{
public string ToCliSpec() => ArraySize is { } n ? $"{Name}:{AbServerType}:{n}" : $"{Name}:{AbServerType}";
}
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 912.</summary>
public static class KnownProfiles
{
/// <summary>
/// ControlLogix — the widest-coverage family: full CIP capabilities, generous connection
/// size, @tags controller-walk supported. Tag shape covers atomic types + a Program-scoped
/// tag so the Symbol-Object decoder's scope-split path is exercised.
/// </summary>
public static readonly AbServerProfile ControlLogix = new(
Family: AbCipPlcFamily.ControlLogix,
AbServerPlcArg: "controllogix",
SeedTags: new AbServerSeedTag[]
{
new("TestDINT", "DINT"),
new("TestREAL", "REAL"),
new("TestBOOL", "BOOL"),
new("TestSINT", "SINT"),
new("TestString","STRING"),
new("TestArray", "DINT", ArraySize: 16),
},
Notes: "Widest-coverage profile — PR 9 baseline. UDTs live in PR 6-shipped Template Object tests; ab_server lacks full UDT emulation.");
/// <summary>
/// CompactLogix — narrower ConnectionSize quirk exercised here. ab_server doesn't
/// enforce the narrower limit itself; the driver-side profile caps it + this simulator
/// honors whatever the client asks for. Tag set is a subset of ControlLogix.
/// </summary>
public static readonly AbServerProfile CompactLogix = new(
Family: AbCipPlcFamily.CompactLogix,
AbServerPlcArg: "compactlogix",
SeedTags: new AbServerSeedTag[]
{
new("TestDINT", "DINT"),
new("TestREAL", "REAL"),
new("TestBOOL", "BOOL"),
},
Notes: "Narrower ConnectionSize than ControlLogix — driver-side profile caps it per PR 10. Tag set mirrors the CompactLogix atomic subset.");
/// <summary>
/// Micro800 — unconnected-only family. ab_server has no explicit micro800 plc mode so
/// we fall back to the nearest CIP-compatible emulation (controllogix) + document the
/// discrepancy. Driver-side path enforcement (empty routing path, unconnected-only
/// sessions) is exercised in the unit suite; this integration profile smoke-tests that
/// reads work end-to-end against the unconnected path.
/// </summary>
public static readonly AbServerProfile Micro800 = new(
Family: AbCipPlcFamily.Micro800,
AbServerPlcArg: "controllogix", // ab_server lacks dedicated micro800 mode — see Notes
SeedTags: new AbServerSeedTag[]
{
new("TestDINT", "DINT"),
new("TestREAL", "REAL"),
},
Notes: "ab_server has no --plc micro800 — falls back to controllogix emulation. Driver side still enforces empty path + unconnected-only per PR 11. Real Micro800 coverage requires a 2080 on a lab rig.");
/// <summary>
/// GuardLogix — safety-capable ControlLogix variant with ViewOnly safety tags. ab_server
/// doesn't emulate the safety subsystem; we preseed a safety-suffixed name (<c>_S</c>) so
/// the driver's read-only classification path is exercised against a real tag.
/// </summary>
public static readonly AbServerProfile GuardLogix = new(
Family: AbCipPlcFamily.GuardLogix,
AbServerPlcArg: "controllogix",
SeedTags: new AbServerSeedTag[]
{
new("TestDINT", "DINT"),
new("SafetyDINT_S", "DINT"), // _S-suffixed → driver classifies as safety-ViewOnly per PR 12
},
Notes: "ab_server has no safety subsystem — this profile emulates the tag-naming contract. Real safety-lock behavior requires a physical GuardLogix 1756-L8xS rig.");
public static IReadOnlyList<AbServerProfile> All { get; } =
new[] { ControlLogix, CompactLogix, Micro800, GuardLogix };
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
All.FirstOrDefault(p => p.Family == family)
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
}

View File

@@ -0,0 +1,90 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// Pure-unit tests for the profile → CLI arg composition. Runs without <c>ab_server</c>
/// on PATH so CI without the binary still exercises these contracts + catches any
/// profile-definition drift (e.g. a typo in <c>--plc</c> mapping would silently make the
/// simulator boot with the wrong family).
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbServerProfileTests
{
[Fact]
public void BuildCliArgs_Emits_Port_And_Plc_And_TagFlags()
{
var profile = new AbServerProfile(
Family: AbCipPlcFamily.ControlLogix,
AbServerPlcArg: "controllogix",
SeedTags: new AbServerSeedTag[]
{
new("A", "DINT"),
new("B", "REAL"),
},
Notes: "test");
profile.BuildCliArgs(44818).ShouldBe("--port 44818 --plc controllogix --tag A:DINT --tag B:REAL");
}
[Fact]
public void BuildCliArgs_NoSeedTags_Emits_Just_Port_And_Plc()
{
var profile = new AbServerProfile(
AbCipPlcFamily.ControlLogix, "controllogix", [], "empty");
profile.BuildCliArgs(5000).ShouldBe("--port 5000 --plc controllogix");
}
[Fact]
public void AbServerSeedTag_ArraySize_FormatsAsThirdSegment()
{
new AbServerSeedTag("TestArray", "DINT", ArraySize: 16)
.ToCliSpec().ShouldBe("TestArray:DINT:16");
}
[Fact]
public void AbServerSeedTag_NoArraySize_TwoSegments()
{
new AbServerSeedTag("TestScalar", "REAL")
.ToCliSpec().ShouldBe("TestScalar:REAL");
}
[Theory]
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
[InlineData(AbCipPlcFamily.Micro800, "controllogix")] // falls back — ab_server lacks dedicated mode
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")] // falls back — ab_server lacks safety subsystem
public void KnownProfiles_ForFamily_Returns_Expected_AbServerPlcArg(AbCipPlcFamily family, string expected)
{
KnownProfiles.ForFamily(family).AbServerPlcArg.ShouldBe(expected);
}
[Fact]
public void KnownProfiles_All_Covers_Every_Family()
{
var covered = KnownProfiles.All.Select(p => p.Family).ToHashSet();
foreach (var family in Enum.GetValues<AbCipPlcFamily>())
covered.ShouldContain(family, $"Family {family} is missing a KnownProfiles entry.");
}
[Fact]
public void KnownProfiles_ControlLogix_Includes_AllAtomicTypes()
{
var tags = KnownProfiles.ControlLogix.SeedTags.Select(t => t.AbServerType).ToHashSet();
tags.ShouldContain("DINT");
tags.ShouldContain("REAL");
tags.ShouldContain("BOOL");
tags.ShouldContain("SINT");
tags.ShouldContain("STRING");
}
[Fact]
public void KnownProfiles_GuardLogix_SeedsSafetySuffixedTag()
{
KnownProfiles.GuardLogix.SeedTags
.ShouldContain(t => t.Name.EndsWith("_S"), "GuardLogix profile must seed at least one _S-suffixed tag for safety-classification coverage.");
}
}