Compare commits
15 Commits
diff-viewe
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| d93dc73978 | |||
|
|
ecc2389ca8 | ||
| 852c710013 | |||
|
|
8ce5791f49 | ||
| 05ddea307b | |||
|
|
32dff7f1d6 | ||
| 42649ca7b0 | |||
|
|
1f3343e61f | ||
| 251f567b98 | |||
|
|
404bfbe7e4 | ||
| 006af636a0 | |||
|
|
c0751fdda5 | ||
| 80e080ecec | |||
|
|
5ee510dc1a | ||
| 543665dedd |
20
ci/ab-server.lock.json
Normal file
20
ci/ab-server.lock.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"_comment": "Pinned libplctag release used by tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture. ab_server.exe ships inside the *_tools.zip asset on every GitHub release. See docs/v2/test-data-sources.md §2.CI for the GitHub Actions step that consumes this file.",
|
||||
"repo": "libplctag/libplctag",
|
||||
"tag": "v2.6.16",
|
||||
"published": "2026-03-29",
|
||||
"assets": {
|
||||
"windows-x64": {
|
||||
"file": "libplctag_2.6.16_windows_x64_tools.zip",
|
||||
"sha256": "9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232"
|
||||
},
|
||||
"windows-x86": {
|
||||
"file": "libplctag_2.6.16_windows_x86_tools.zip",
|
||||
"sha256": "fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf"
|
||||
},
|
||||
"windows-arm64": {
|
||||
"file": "libplctag_2.6.16_windows_arm64_tools.zip",
|
||||
"sha256": "d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,43 @@ 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.
|
||||
|
||||
**Pinned version** (recorded in `ci/ab-server.lock.json` so drift is one-file visible):
|
||||
|
||||
- `libplctag` **v2.6.16** (published 2026-03-29) — `ab_server.exe` ships inside the `_tools.zip` asset alongside `plctag.dll` + two `list_tags_*` helpers.
|
||||
- Windows x64: `libplctag_2.6.16_windows_x64_tools.zip` — SHA256 `9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232`
|
||||
- Windows x86: `libplctag_2.6.16_windows_x86_tools.zip` — SHA256 `fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf`
|
||||
- Windows ARM64: `libplctag_2.6.16_windows_arm64_tools.zip` — SHA256 `d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944`
|
||||
|
||||
**CI step:**
|
||||
|
||||
```yaml
|
||||
# GitHub Actions step placed before `dotnet test`:
|
||||
- name: Fetch ab_server (libplctag v2.6.16)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pin = Get-Content ci/ab-server.lock.json | ConvertFrom-Json
|
||||
$asset = $pin.assets.'windows-x64' # swap to windows-x86 / windows-arm64 on non-x64 runners
|
||||
$url = "https://github.com/libplctag/libplctag/releases/download/$($pin.tag)/$($asset.file)"
|
||||
$zip = Join-Path $env:RUNNER_TEMP 'libplctag-tools.zip'
|
||||
Invoke-WebRequest $url -OutFile $zip
|
||||
$actual = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
||||
if ($actual -ne $asset.sha256) { throw "libplctag tools SHA256 mismatch: expected $($asset.sha256), got $actual" }
|
||||
$dest = Join-Path $env:RUNNER_TEMP 'libplctag-tools'
|
||||
Expand-Archive $zip -DestinationPath $dest
|
||||
Add-Content $env:GITHUB_PATH $dest
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Authorization
|
||||
@inject NodeAclService AclSvc
|
||||
@inject PermissionProbeService ProbeSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Access-control grants</h4>
|
||||
@@ -29,6 +31,95 @@ else
|
||||
</table>
|
||||
}
|
||||
|
||||
@* Probe-this-permission — task #196 slice 1 *@
|
||||
<div class="card mt-4 mb-3">
|
||||
<div class="card-header">
|
||||
<strong>Probe this permission</strong>
|
||||
<span class="small text-muted ms-2">
|
||||
Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" —
|
||||
answers the same way the live server does at request time.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">LDAP group</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeGroup" placeholder="cn=fleet-admin,…"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeNamespaceId" placeholder="ns-1"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsArea</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsAreaId"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsLine</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsLineId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Equipment</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeEquipmentId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Tag</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeTagId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Permission</label>
|
||||
<select class="form-select form-select-sm" @bind="_probePermission">
|
||||
@foreach (var p in Enum.GetValues<NodePermissions>())
|
||||
{
|
||||
if (p == NodePermissions.None) continue;
|
||||
<option value="@p">@p</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RunProbeAsync" disabled="@_probing">Probe</button>
|
||||
@if (_probeResult is not null)
|
||||
{
|
||||
<span class="ms-3">
|
||||
@if (_probeResult.Granted)
|
||||
{
|
||||
<span class="badge bg-success">Granted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Denied</span>
|
||||
}
|
||||
<span class="small ms-2">
|
||||
Required <code>@_probeResult.Required</code>,
|
||||
Effective <code>@_probeResult.Effective</code>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (_probeResult is not null && _probeResult.Matches.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mt-3 mb-0">
|
||||
<thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _probeResult.Matches)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@m.LdapGroup</code></td>
|
||||
<td>@m.Scope</td>
|
||||
<td><code>@m.PermissionFlags</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else if (_probeResult is not null)
|
||||
{
|
||||
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <code>None</code>.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
@@ -80,6 +171,40 @@ else
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
// Probe-this-permission state
|
||||
private string _probeGroup = string.Empty;
|
||||
private string _probeNamespaceId = string.Empty;
|
||||
private string _probeUnsAreaId = string.Empty;
|
||||
private string _probeUnsLineId = string.Empty;
|
||||
private string _probeEquipmentId = string.Empty;
|
||||
private string _probeTagId = string.Empty;
|
||||
private NodePermissions _probePermission = NodePermissions.Read;
|
||||
private PermissionProbeResult? _probeResult;
|
||||
private bool _probing;
|
||||
|
||||
private async Task RunProbeAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; }
|
||||
_probing = true;
|
||||
try
|
||||
{
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = NullIfBlank(_probeNamespaceId),
|
||||
UnsAreaId = NullIfBlank(_probeUnsAreaId),
|
||||
UnsLineId = NullIfBlank(_probeUnsLineId),
|
||||
EquipmentId = NullIfBlank(_probeEquipmentId),
|
||||
TagId = NullIfBlank(_probeTagId),
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
_probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None);
|
||||
}
|
||||
finally { _probing = false; }
|
||||
}
|
||||
|
||||
private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
|
||||
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
|
||||
refresh-required modal instead of clobbering their work.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
@@ -14,11 +21,20 @@
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
||||
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||
@ondragover:preventDefault
|
||||
@ondragleave="() => _hoverAreaId = null"
|
||||
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||
@ondrop:preventDefault>
|
||||
<td><code>@a.UnsAreaId</code></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="small text-muted">drop here</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -35,6 +51,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Lines</h4>
|
||||
@@ -50,7 +67,14 @@
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
||||
<tr draggable="true"
|
||||
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||
style="cursor: grab;">
|
||||
<td><code>@l.UnsLineId</code></td>
|
||||
<td><code>@l.UnsAreaId</code></td>
|
||||
<td>@l.Name</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -75,6 +99,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Preview / confirm modal for a pending drag-drop move *@
|
||||
@if (_pendingPreview is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm UNS move</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||
<p class="text-muted small">
|
||||
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||
</p>
|
||||
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||
@if (_conflictMessage is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_conflictMessage</p>
|
||||
<p class="small text-muted">
|
||||
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
@@ -87,6 +169,13 @@
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
private string? _dragLineId;
|
||||
private string? _hoverAreaId;
|
||||
private UnsImpactPreview? _pendingPreview;
|
||||
private UnsMoveOperation? _pendingMove;
|
||||
private bool _committing;
|
||||
private string? _conflictMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
@@ -112,4 +201,72 @@
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
|
||||
|
||||
private async Task OnLineDroppedAsync(string targetAreaId)
|
||||
{
|
||||
var lineId = _dragLineId;
|
||||
_hoverAreaId = null;
|
||||
_dragLineId = null;
|
||||
if (string.IsNullOrWhiteSpace(lineId)) return;
|
||||
|
||||
var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
|
||||
if (line is null || line.UnsAreaId == targetAreaId) return;
|
||||
|
||||
var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: ClusterId,
|
||||
TargetClusterId: ClusterId,
|
||||
SourceLineId: lineId,
|
||||
TargetAreaId: targetAreaId);
|
||||
try
|
||||
{
|
||||
_pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
_pendingMove = move;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelMove()
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmMoveAsync()
|
||||
{
|
||||
if (_pendingPreview is null || _pendingMove is null) return;
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
await UnsSvc.MoveLineAsync(
|
||||
GenerationId,
|
||||
_pendingPreview.RevisionToken,
|
||||
_pendingMove.SourceLineId!,
|
||||
_pendingMove.TargetAreaId!,
|
||||
CancellationToken.None);
|
||||
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (DraftRevisionConflictException ex)
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
_conflictMessage = ex.Message;
|
||||
}
|
||||
finally { _committing = false; }
|
||||
}
|
||||
|
||||
private async Task ReloadAfterConflict()
|
||||
{
|
||||
_conflictMessage = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -44,11 +44,13 @@ builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
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>();
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.Services;
|
||||
|
||||
@@ -152,14 +153,37 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var row in batch.Rows.Where(r => r.IsAccepted))
|
||||
// Snapshot active reservations that overlap this batch's ZTag + SAPID set — one
|
||||
// round-trip instead of N. Released rows (ReleasedAt IS NOT NULL) are ignored so
|
||||
// an explicitly-released value can be reused.
|
||||
var accepted = batch.Rows.Where(r => r.IsAccepted).ToList();
|
||||
var zTags = accepted.Where(r => !string.IsNullOrWhiteSpace(r.ZTag))
|
||||
.Select(r => r.ZTag).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var sapIds = accepted.Where(r => !string.IsNullOrWhiteSpace(r.SAPID))
|
||||
.Select(r => r.SAPID).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingReservations = await db.ExternalIdReservations
|
||||
.Where(r => r.ReleasedAt == null &&
|
||||
((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) ||
|
||||
(r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value))))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
var resByKey = existingReservations.ToDictionary(
|
||||
r => (r.Kind, r.Value.ToLowerInvariant()),
|
||||
r => r);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var firstPublishedBy = batch.CreatedBy;
|
||||
|
||||
foreach (var row in accepted)
|
||||
{
|
||||
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = generationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid(),
|
||||
EquipmentUuid = equipmentUuid,
|
||||
DriverInstanceId = driverInstanceIdForRows,
|
||||
UnsLineId = unsLineIdForRows,
|
||||
Name = row.Name,
|
||||
@@ -176,10 +200,25 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
|
||||
MergeReservation(row.ZTag, ReservationKind.ZTag, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
MergeReservation(row.SAPID, ReservationKind.SAPID, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
}
|
||||
|
||||
batch.FinalisedAtUtc = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
batch.FinalisedAtUtc = nowUtc;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsReservationUniquenessViolation(ex))
|
||||
{
|
||||
throw new ExternalIdReservationConflictException(
|
||||
"Finalise rejected: one or more ZTag/SAPID values were reserved by another operator " +
|
||||
"between batch preview and commit. Inspect active reservations + retry after resolving the conflict.",
|
||||
ex);
|
||||
}
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
@@ -193,6 +232,71 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge one external-ID reservation for an equipment row. Three outcomes:
|
||||
/// (1) value is empty → skip; (2) reservation exists for same <paramref name="equipmentUuid"/>
|
||||
/// → bump <c>LastPublishedAt</c>; (3) reservation exists for a different EquipmentUuid
|
||||
/// → throw <see cref="ExternalIdReservationConflictException"/> with the conflicting UUID
|
||||
/// so the caller sees which equipment already owns the value; (4) no reservation → create new.
|
||||
/// </summary>
|
||||
private void MergeReservation(
|
||||
string? value,
|
||||
ReservationKind kind,
|
||||
Guid equipmentUuid,
|
||||
string clusterId,
|
||||
string firstPublishedBy,
|
||||
DateTime nowUtc,
|
||||
Dictionary<(ReservationKind, string), ExternalIdReservation> cache)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
|
||||
var key = (kind, value.ToLowerInvariant());
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.EquipmentUuid != equipmentUuid)
|
||||
throw new ExternalIdReservationConflictException(
|
||||
$"{kind} '{value}' is already reserved by EquipmentUuid {existing.EquipmentUuid} " +
|
||||
$"(first published {existing.FirstPublishedAt:u} on cluster '{existing.ClusterId}'). " +
|
||||
$"Refusing to re-assign to {equipmentUuid}.");
|
||||
|
||||
existing.LastPublishedAt = nowUtc;
|
||||
return;
|
||||
}
|
||||
|
||||
var fresh = new ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = kind,
|
||||
Value = value,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
ClusterId = clusterId,
|
||||
FirstPublishedAt = nowUtc,
|
||||
FirstPublishedBy = firstPublishedBy,
|
||||
LastPublishedAt = nowUtc,
|
||||
};
|
||||
db.ExternalIdReservations.Add(fresh);
|
||||
cache[key] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the <see cref="DbUpdateException"/> root-cause was the filtered-unique
|
||||
/// index <c>UX_ExternalIdReservation_KindValue_Active</c> — i.e. another transaction
|
||||
/// won the race between our cache-load + commit. SQL Server surfaces this as 2601 / 2627.
|
||||
/// </summary>
|
||||
private static bool IsReservationUniquenessViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? inner = ex; inner is not null; inner = inner.InnerException)
|
||||
{
|
||||
if (inner is Microsoft.Data.SqlClient.SqlException sql &&
|
||||
(sql.Number == 2601 || sql.Number == 2627) &&
|
||||
sql.Message.Contains("UX_ExternalIdReservation_KindValue_Active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||
{
|
||||
@@ -205,3 +309,16 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
|
||||
public sealed class ImportBatchNotFoundException(string message) : Exception(message);
|
||||
public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <c>FinaliseBatchAsync</c> call detects that one of its ZTag/SAPID values is
|
||||
/// already reserved by a different EquipmentUuid — either from a prior published generation
|
||||
/// or a concurrent finalise that won the race. The operator sees the message + the conflicting
|
||||
/// equipment ownership so they can resolve the conflict (pick a new ZTag, release the existing
|
||||
/// reservation via <c>sp_ReleaseExternalIdReservation</c>, etc.) and retry the finalise.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservationConflictException : Exception
|
||||
{
|
||||
public ExternalIdReservationConflictException(string message) : base(message) { }
|
||||
public ExternalIdReservationConflictException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an ad-hoc permission probe against a draft or published generation's NodeAcl rows —
|
||||
/// "if LDAP group X asks for permission Y on node Z, would the trie grant it, and which
|
||||
/// rows contributed?" Powers the AclsTab "Probe this permission" form per the #196 sub-slice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Thin wrapper over <see cref="PermissionTrieBuilder"/> + <see cref="PermissionTrie.CollectMatches"/> —
|
||||
/// the same code path the Server's dispatch layer uses at request time, so a probe result
|
||||
/// is guaranteed to match what the live server would decide. The probe is read-only + has
|
||||
/// no side effects; failing probes do NOT generate audit log rows.
|
||||
/// </remarks>
|
||||
public sealed class PermissionProbeService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="required"/> against the NodeAcl rows of
|
||||
/// <paramref name="generationId"/> for a request by <paramref name="ldapGroup"/> at
|
||||
/// <paramref name="scope"/>. Returns whether the permission would be granted + the list
|
||||
/// of matching grants so the UI can show *why*.
|
||||
/// </summary>
|
||||
public async Task<PermissionProbeResult> ProbeAsync(
|
||||
long generationId,
|
||||
string ldapGroup,
|
||||
NodeScope scope,
|
||||
NodePermissions required,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
var rows = await db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && a.ClusterId == scope.ClusterId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var trie = PermissionTrieBuilder.Build(scope.ClusterId, generationId, rows);
|
||||
var matches = trie.CollectMatches(scope, [ldapGroup]);
|
||||
|
||||
var effective = NodePermissions.None;
|
||||
foreach (var m in matches)
|
||||
effective |= m.PermissionFlags;
|
||||
|
||||
var granted = (effective & required) == required;
|
||||
return new PermissionProbeResult(
|
||||
Granted: granted,
|
||||
Required: required,
|
||||
Effective: effective,
|
||||
Matches: matches);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a <see cref="PermissionProbeService.ProbeAsync"/> call.</summary>
|
||||
public sealed record PermissionProbeResult(
|
||||
bool Granted,
|
||||
NodePermissions Required,
|
||||
NodePermissions Effective,
|
||||
IReadOnlyList<MatchedGrant> Matches);
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs
Normal 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);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||
/// </summary>
|
||||
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
var areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.GroupBy(e => e.UnsLineId)
|
||||
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lineSummaries = lines.Select(l =>
|
||||
new UnsLineSummary(
|
||||
LineId: l.UnsLineId,
|
||||
Name: l.Name,
|
||||
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||
TagCount: 0)).ToList();
|
||||
|
||||
var areaSummaries = areas.Select(a =>
|
||||
new UnsAreaSummary(
|
||||
AreaId: a.UnsAreaId,
|
||||
Name: a.Name,
|
||||
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||
|
||||
return new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = generationId,
|
||||
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||
Areas = areaSummaries,
|
||||
Lines = lineSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||
/// the revision token it observed at preview time — a mismatch raises
|
||||
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||
/// modal instead of silently overwriting a peer's work.
|
||||
/// </summary>
|
||||
public async Task MoveLineAsync(
|
||||
long generationId,
|
||||
DraftRevisionToken expected,
|
||||
string lineId,
|
||||
string targetAreaId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var areas = await db.UnsAreas
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var current = ComputeRevisionToken(areas, lines);
|
||||
if (!current.Matches(expected))
|
||||
throw new DraftRevisionConflictException(
|
||||
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||
"Refresh + redo the move.");
|
||||
|
||||
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||
|
||||
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||
|
||||
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
return; // no-op drop — same area
|
||||
|
||||
line.UnsAreaId = targetAreaId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||
/// "refresh required" modal in the Admin UI.</summary>
|
||||
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
129
src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs
Normal file
129
src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the three mutating surfaces of <see cref="IAlarmSource"/>
|
||||
/// (<see cref="IAlarmSource.SubscribeAlarmsAsync"/>, <see cref="IAlarmSource.UnsubscribeAlarmsAsync"/>,
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>) through <see cref="CapabilityInvoker"/> so the
|
||||
/// Phase 6.1 resilience pipeline runs — retry semantics match
|
||||
/// <see cref="DriverCapability.AlarmSubscribe"/> (retries by default) and
|
||||
/// <see cref="DriverCapability.AlarmAcknowledge"/> (does NOT retry per decision #143).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host dispatch: when the driver implements <see cref="IPerCallHostResolver"/>,
|
||||
/// each source-node-id is resolved individually + grouped by host so a dead PLC inside a
|
||||
/// multi-device driver doesn't poison the sibling hosts' breakers. Drivers with a single
|
||||
/// host fall back to <see cref="IDriver.DriverInstanceId"/> as the single-host key.</para>
|
||||
///
|
||||
/// <para>Why this lives here + not on <see cref="CapabilityInvoker"/>: alarm surfaces have a
|
||||
/// handle-returning shape (SubscribeAlarmsAsync returns <see cref="IAlarmSubscriptionHandle"/>)
|
||||
/// + a per-call fan-out (AcknowledgeAsync gets a batch of
|
||||
/// <see cref="AlarmAcknowledgeRequest"/>s that may span multiple hosts). Keeping the fan-out
|
||||
/// logic here keeps the invoker's execute-overloads narrow.</para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmSurfaceInvoker
|
||||
{
|
||||
private readonly CapabilityInvoker _invoker;
|
||||
private readonly IAlarmSource _alarmSource;
|
||||
private readonly IPerCallHostResolver? _hostResolver;
|
||||
private readonly string _defaultHost;
|
||||
|
||||
public AlarmSurfaceInvoker(
|
||||
CapabilityInvoker invoker,
|
||||
IAlarmSource alarmSource,
|
||||
string defaultHost,
|
||||
IPerCallHostResolver? hostResolver = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoker);
|
||||
ArgumentNullException.ThrowIfNull(alarmSource);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(defaultHost);
|
||||
|
||||
_invoker = invoker;
|
||||
_alarmSource = alarmSource;
|
||||
_defaultHost = defaultHost;
|
||||
_hostResolver = hostResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to alarm events for a set of source node ids, fanning out by resolved host
|
||||
/// so per-host breakers / bulkheads apply. Returns one handle per host — callers that
|
||||
/// don't care about per-host separation may concatenate them.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||
if (sourceNodeIds.Count == 0) return [];
|
||||
|
||||
var byHost = GroupByHost(sourceNodeIds);
|
||||
var handles = new List<IAlarmSubscriptionHandle>(byHost.Count);
|
||||
foreach (var (host, ids) in byHost)
|
||||
{
|
||||
var handle = await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
host,
|
||||
async ct => await _alarmSource.SubscribeAlarmsAsync(ids, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
handles.Add(handle);
|
||||
}
|
||||
return handles;
|
||||
}
|
||||
|
||||
/// <summary>Cancel an alarm subscription. Routes through the AlarmSubscribe pipeline for parity.</summary>
|
||||
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
return _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
_defaultHost,
|
||||
async ct => await _alarmSource.UnsubscribeAlarmsAsync(handle, ct).ConfigureAwait(false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge alarms. Fans out by resolved host; each host's batch runs through the
|
||||
/// AlarmAcknowledge pipeline (no-retry per decision #143 — an alarm-ack is not idempotent
|
||||
/// at the plant-floor acknowledgement level even if the OPC UA spec permits re-issue).
|
||||
/// </summary>
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||
if (acknowledgements.Count == 0) return;
|
||||
|
||||
var byHost = _hostResolver is null
|
||||
? new Dictionary<string, List<AlarmAcknowledgeRequest>> { [_defaultHost] = acknowledgements.ToList() }
|
||||
: acknowledgements
|
||||
.GroupBy(a => _hostResolver.ResolveHost(a.SourceNodeId))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var (host, batch) in byHost)
|
||||
{
|
||||
var batchSnapshot = batch; // capture for the lambda
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmAcknowledge,
|
||||
host,
|
||||
async ct => await _alarmSource.AcknowledgeAsync(batchSnapshot, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, List<string>> GroupByHost(IReadOnlyList<string> sourceNodeIds)
|
||||
{
|
||||
if (_hostResolver is null)
|
||||
return new Dictionary<string, List<string>> { [_defaultHost] = sourceNodeIds.ToList() };
|
||||
|
||||
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var id in sourceNodeIds)
|
||||
{
|
||||
var host = _hostResolver.ResolveHost(id);
|
||||
if (!result.TryGetValue(host, out var list))
|
||||
result[host] = list = new List<string>();
|
||||
list.Add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,13 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
|
||||
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
|
||||
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
|
||||
{
|
||||
ZTag = zTag,
|
||||
MachineCode = "mc",
|
||||
SAPID = "sap",
|
||||
SAPID = $"sap-{zTag}",
|
||||
EquipmentId = "eq-id",
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
@@ -162,4 +164,93 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
// no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_Creates_ExternalIdReservations_ForZTagAndSAPID()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-new-1")], [], CancellationToken.None);
|
||||
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var active = await _db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.ToListAsync();
|
||||
active.Count.ShouldBe(2);
|
||||
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag && r.Value == "z-new-1");
|
||||
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID && r.Value == "sap-z-new-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_SameEquipmentUuid_ReusesExistingReservation()
|
||||
{
|
||||
var batch1 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var sharedUuid = Guid.NewGuid();
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
|
||||
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
|
||||
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch1.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var countAfterFirst = _db.ExternalIdReservations.Count(r => r.ReleasedAt == null);
|
||||
|
||||
// Second finalise with same EquipmentUuid + same ZTag — should NOT create a duplicate.
|
||||
var batch2 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch2.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch2.Id, 2, "drv", "line", CancellationToken.None);
|
||||
|
||||
_db.ExternalIdReservations.Count(r => r.ReleasedAt == null).ShouldBe(countAfterFirst);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_DifferentEquipmentUuid_SameZTag_Throws_Conflict()
|
||||
{
|
||||
var batchA = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var rowA = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
|
||||
EquipmentId = "eq-a", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
||||
var rowB = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
|
||||
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
|
||||
|
||||
var ex = await Should.ThrowAsync<ExternalIdReservationConflictException>(() =>
|
||||
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
|
||||
ex.Message.ShouldContain("z-collide");
|
||||
|
||||
// Second finalise must have rolled back — no partial Equipment row for batch B.
|
||||
var equipmentB = await _db.Equipment.AsNoTracking()
|
||||
.Where(e => e.EquipmentId == "eq-b")
|
||||
.ToListAsync();
|
||||
equipmentB.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_EmptyZTagAndSAPID_SkipsReservation()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "", MachineCode = "mc", SAPID = "",
|
||||
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
_db.ExternalIdReservations.Count().ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
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;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionProbeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Probe_Grants_When_ClusterLevelRow_CoversRequiredFlag()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, gen: 1, cluster: "c1",
|
||||
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
|
||||
group: "cn=operators", flags: NodePermissions.Browse | NodePermissions.Read);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var result = await svc.ProbeAsync(
|
||||
generationId: 1,
|
||||
ldapGroup: "cn=operators",
|
||||
scope: new NodeScope { ClusterId = "c1", NamespaceId = "ns-1", Kind = NodeHierarchyKind.Equipment },
|
||||
required: NodePermissions.Read,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Granted.ShouldBeTrue();
|
||||
result.Matches.Count.ShouldBe(1);
|
||||
result.Matches[0].LdapGroup.ShouldBe("cn=operators");
|
||||
result.Matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Denies_When_NoGroupMatches()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var result = await svc.ProbeAsync(1, "cn=random-group",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.Read, CancellationToken.None);
|
||||
|
||||
result.Granted.ShouldBeFalse();
|
||||
result.Matches.ShouldBeEmpty();
|
||||
result.Effective.ShouldBe(NodePermissions.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Denies_When_Effective_Missing_RequiredFlag()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Browse | NodePermissions.Read);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var result = await svc.ProbeAsync(1, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
required: NodePermissions.WriteOperate,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Granted.ShouldBeFalse();
|
||||
result.Effective.ShouldBe(NodePermissions.Browse | NodePermissions.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Ignores_Rows_From_OtherClusters()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||
SeedAcl(ctx, 1, "c2", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var c1Result = await svc.ProbeAsync(1, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.WriteOperate, CancellationToken.None);
|
||||
|
||||
c1Result.Granted.ShouldBeFalse("c2's WriteOperate grant must NOT leak into c1's probe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_UsesOnlyRows_From_Specified_Generation()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, gen: 1, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||
SeedAcl(ctx, gen: 2, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var gen1 = await svc.ProbeAsync(1, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.WriteOperate, CancellationToken.None);
|
||||
var gen2 = await svc.ProbeAsync(2, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.WriteOperate, CancellationToken.None);
|
||||
|
||||
gen1.Granted.ShouldBeFalse();
|
||||
gen2.Granted.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static void SeedAcl(
|
||||
OtOpcUaConfigDbContext ctx, long gen, string cluster,
|
||||
NodeAclScopeKind scopeKind, string? scopeId, string group, NodePermissions flags)
|
||||
{
|
||||
ctx.NodeAcls.Add(new NodeAcl
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..16],
|
||||
GenerationId = gen,
|
||||
ClusterId = cluster,
|
||||
LdapGroup = group,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsServiceMoveTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") },
|
||||
equipmentLines: new[] { "line-a", "line-a", "line-b" });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
snap.Areas.Count.ShouldBe(2);
|
||||
snap.Lines.Count.ShouldBe(3);
|
||||
snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2);
|
||||
snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1);
|
||||
snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var first = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
var second = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var before = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None);
|
||||
var after = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLineAsync_WithMatchingToken_Reparents_Line()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None);
|
||||
|
||||
var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||
moved.UnsAreaId.ShouldBe("area-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
// Simulate a peer operator's concurrent edit between our preview + commit.
|
||||
var stale = new DraftRevisionToken("0000000000000000");
|
||||
|
||||
await Should.ThrowAsync<DraftRevisionConflictException>(() =>
|
||||
svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None));
|
||||
|
||||
var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||
row.UnsAreaId.ShouldBe("area-1");
|
||||
}
|
||||
|
||||
private static void Seed(OtOpcUaConfigDbContext ctx, long draftId,
|
||||
IEnumerable<string> areas,
|
||||
IEnumerable<(string line, string area)> lines,
|
||||
IEnumerable<string>? equipmentLines = null)
|
||||
{
|
||||
foreach (var a in areas)
|
||||
{
|
||||
ctx.UnsAreas.Add(new UnsArea
|
||||
{
|
||||
GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a,
|
||||
});
|
||||
}
|
||||
foreach (var (line, area) in lines)
|
||||
{
|
||||
ctx.UnsLines.Add(new UnsLine
|
||||
{
|
||||
GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line,
|
||||
});
|
||||
}
|
||||
foreach (var lineId in equipmentLines ?? [])
|
||||
{
|
||||
ctx.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = draftId,
|
||||
EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15],
|
||||
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
|
||||
UnsLineId = lineId, Name = "x", MachineCode = "m",
|
||||
});
|
||||
}
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AlarmSurfaceInvokerTests
|
||||
{
|
||||
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var surface = NewSurface(driver, defaultHost: "h");
|
||||
|
||||
var handles = await surface.SubscribeAsync([], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(0);
|
||||
driver.SubscribeCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
var handles = await surface.SubscribeAsync(["src-1", "src-2"], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(1);
|
||||
driver.SubscribeCallCount.ShouldBe(1);
|
||||
driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var resolver = new StubResolver(new Dictionary<string, string>
|
||||
{
|
||||
["src-1"] = "plc-a",
|
||||
["src-2"] = "plc-b",
|
||||
["src-3"] = "plc-a",
|
||||
});
|
||||
var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver);
|
||||
|
||||
var handles = await surface.SubscribeAsync(["src-1", "src-2", "src-3"], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(2); // one per distinct host
|
||||
driver.SubscribeCallCount.ShouldBe(2); // one driver call per host
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
|
||||
{
|
||||
var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true };
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None));
|
||||
|
||||
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_Retries_Transient_Failures()
|
||||
{
|
||||
var driver = new FakeAlarmSource { SubscribeFailuresBeforeSuccess = 2 };
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
await surface.SubscribeAsync(["src"], CancellationToken.None);
|
||||
|
||||
driver.SubscribeCallCount.ShouldBe(3, "AlarmSubscribe retries by default — decision #143");
|
||||
}
|
||||
|
||||
private static AlarmSurfaceInvoker NewSurface(
|
||||
IAlarmSource driver,
|
||||
string defaultHost,
|
||||
IPerCallHostResolver? resolver = null)
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var invoker = new CapabilityInvoker(builder, "drv-1", () => TierAOptions);
|
||||
return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver);
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmSource : IAlarmSource
|
||||
{
|
||||
public int SubscribeCallCount { get; private set; }
|
||||
public int AcknowledgeCallCount { get; private set; }
|
||||
public int SubscribeFailuresBeforeSuccess { get; set; }
|
||||
public bool AcknowledgeShouldThrow { get; set; }
|
||||
public IReadOnlyList<string> LastSubscribedIds { get; private set; } = [];
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
SubscribeCallCount++;
|
||||
LastSubscribedIds = sourceNodeIds;
|
||||
if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess)
|
||||
throw new InvalidOperationException("transient");
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(new StubHandle($"h-{SubscribeCallCount}"));
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
AcknowledgeCallCount++;
|
||||
if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
|
||||
}
|
||||
|
||||
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
|
||||
|
||||
private sealed class StubResolver(Dictionary<string, string> map) : IPerCallHostResolver
|
||||
{
|
||||
public string ResolveHost(string fullReference) => map[fullReference];
|
||||
}
|
||||
}
|
||||
@@ -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 9–12 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <arg></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 <name>:<type>[:<size>]</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><name>:<type>[:<array_size>]</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 9–12.</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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user