Files
lmxopcua/scripts/e2e/test-abcip-hsby.ps1
2026-04-26 08:13:41 -04:00

211 lines
8.4 KiB
PowerShell

#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for AB CIP HSBY failover routing (PR abcip-5.2). Subscribes to
a tag through the OtOpcUa OPC UA server, flips the active chassis mid-stream via
the paired-fixture's hsby-mux sidecar HTTP endpoint, and asserts the subscribe
stream survives the failover (no permanent loss of notifications + the post-flip
data carries the partner-side update).
.DESCRIPTION
Paired-fixture variant of test-abcip.ps1. Where test-abcip.ps1 runs against a
single ab_server instance, this script assumes a paired fixture with two
ab_server instances (primary + partner) and an hsby-mux sidecar exposing
/flip {"active": "primary" | "partner"} over HTTP.
Five assertions:
- HsbyInitialActive — primary is Active at start (hsby-mux primes it)
- HsbyResolveActive — driver-diagnostics surfaces AbCip.HsbyActive == 1
- HsbyFailoverFlip — POST {"active": "partner"} → AbCip.HsbyActive == 2
- HsbySubscribeSurvives — subscribe stream stays open across the flip + sees
an updated value from the partner side
- HsbyFailoverCount — AbCip.HsbyFailoverCount increments by ≥ 1
.PARAMETER PrimaryGateway
ab://host[:port]/cip-path of the primary chassis. Default ab://127.0.0.1/1,0.
.PARAMETER PartnerGateway
ab://host[:port]/cip-path of the partner chassis. Default ab://127.0.0.2/1,0.
.PARAMETER HsbyMuxUrl
Base URL of the paired-fixture's hsby-mux sidecar. Default http://localhost:7080.
Endpoints used:
GET /role → returns {"primary":"Active","partner":"Standby"}
POST /flip {"active":"primary"|"partner"} → flips role tag values on each chassis
.PARAMETER OpcUaUrl
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
.PARAMETER BridgeNodeId
NodeId at which the server publishes the tag exercised by the subscribe assertion.
Required.
.PARAMETER TagPath
Logix symbolic path the bridge tag points at. Default 'TestDINT'.
.PARAMETER DriverInstanceId
DriverInstance ID for the AB CIP driver under test. Used to scope the
driver-diagnostics RPC. Default 'abcip-hsby'.
.EXAMPLE
./test-abcip-hsby.ps1 -BridgeNodeId 'ns=2;s=AbCip/Bridge/TestDINT'
#>
param(
[string]$PrimaryGateway = "ab://127.0.0.1/1,0",
[string]$PartnerGateway = "ab://127.0.0.2/1,0",
[string]$HsbyMuxUrl = "http://localhost:7080",
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId,
[string]$TagPath = "TestDINT",
[string]$DriverInstanceId = "abcip-hsby"
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
$abcipCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" `
-ExeName "otopcua-abcip-cli"
$opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
$results = @()
function Invoke-HsbyFlip {
param([string]$Active)
$body = @{ active = $Active } | ConvertTo-Json -Compress
try {
Invoke-RestMethod -Uri "$HsbyMuxUrl/flip" -Method Post -Body $body -ContentType 'application/json'
} catch {
throw "hsby-mux at $HsbyMuxUrl/flip rejected the request: $($_.Exception.Message)"
}
}
function Get-HsbyDiagnosticValue {
param([string]$Counter)
# Pull driver-diagnostics through the OPC UA Admin RPC surface. The CLI returns
# a raw JSON blob; we grep out the named counter so the assertion is robust to
# other counters the driver surfaces.
$diagArgs = @($opcUaCli.PrefixArgs) + @(
"driver-diagnostics", "-u", $OpcUaUrl, "-d", $DriverInstanceId)
$diagOut = & $opcUaCli.File @diagArgs 2>&1
$joined = ($diagOut -join "`n")
if ($joined -match "${Counter}.*?:\s*([\d\.]+)") {
return [double]$matches[1]
}
return $null
}
# ---- HsbyInitialActive — hsby-mux primes primary as Active ----
Write-Header "HsbyInitialActive (POST $HsbyMuxUrl/flip {active=primary})"
try {
Invoke-HsbyFlip -Active "primary" | Out-Null
Start-Sleep -Seconds 3 # role-probe loop default tick is 2s
$active = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive"
$passed = ($active -eq 1.0)
$results += [PSCustomObject]@{
Name = "HsbyInitialActive"
Passed = $passed
Detail = if ($passed) { "AbCip.HsbyActive=1 after priming primary" } else { "AbCip.HsbyActive=$active (expected 1)" }
}
} catch {
$results += [PSCustomObject]@{
Name = "HsbyInitialActive"; Passed = $false; Detail = $_.Exception.Message
}
}
# ---- HsbyResolveActive — driver routing reads through the primary ----
Write-Header "HsbyResolveActive (read $TagPath via primary)"
$readArgs = @("read") + @("-g", $PrimaryGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt")
$readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs) 2>&1
$readOk = ($readOut -join "`n") -notmatch "(error|fail)"
$results += [PSCustomObject]@{
Name = "HsbyResolveActive"
Passed = $readOk
Detail = if ($readOk) { "primary read completed without error" } else { "read failed: $($readOut -join ' ')" }
}
# ---- HsbySubscribeSurvives + HsbyFailoverFlip + HsbyFailoverCount ----
Write-Header "HsbyFailoverFlip + HsbySubscribeSurvives (subscribe across flip)"
$failoverBaseline = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount"
if ($null -eq $failoverBaseline) { $failoverBaseline = 0 }
$duration = 12
$subOut = New-TemporaryFile
$subErr = New-TemporaryFile
$subArgs = @($opcUaCli.PrefixArgs) + @(
"subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId, "-i", "200", "--duration", "$duration")
$subProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $subArgs `
-NoNewWindow -PassThru `
-RedirectStandardOutput $subOut.FullName `
-RedirectStandardError $subErr.FullName
# Let the subscribe settle + accumulate primary-side notifications.
Start-Sleep -Seconds 3
# Mid-stream flip — primary→Standby, partner→Active.
try {
Invoke-HsbyFlip -Active "partner" | Out-Null
} catch {
Stop-Process -Id $subProc.Id -Force -ErrorAction SilentlyContinue
$results += [PSCustomObject]@{
Name = "HsbyFailoverFlip"; Passed = $false; Detail = "hsby-mux flip rejected: $($_.Exception.Message)"
}
}
# Wait for the role-probe loop to catch up (default tick 2s + ProbeIntervalMs slack).
Start-Sleep -Seconds 4
# Drive a write through the partner so the subscribe sees a fresh value.
$flipValue = Get-Random -Minimum 70000 -Maximum 79999
$writeArgs = @("write") + @("-g", $PartnerGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt", "-v", $flipValue)
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
$activeAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive"
$flipPassed = ($activeAfter -eq 2.0)
$results += [PSCustomObject]@{
Name = "HsbyFailoverFlip"
Passed = $flipPassed
Detail = if ($flipPassed) { "AbCip.HsbyActive=2 after flip" } else { "AbCip.HsbyActive=$activeAfter (expected 2)" }
}
# Stop the subscribe + harvest the stream.
$subProc.WaitForExit(($duration + 5) * 1000) | Out-Null
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
$subText = (Get-Content $subOut.FullName -Raw) + (Get-Content $subErr.FullName -Raw)
Remove-Item $subOut.FullName, $subErr.FullName -ErrorAction SilentlyContinue
# Stream survival = at least one notification *after* the flip carries the new
# partner-side value. The post-flip write of $flipValue is the canary.
$saw = $subText -match "$flipValue"
$results += [PSCustomObject]@{
Name = "HsbySubscribeSurvives"
Passed = $saw
Detail = if ($saw) {
"subscribe stream surfaced post-flip value $flipValue from partner chassis"
} else {
"subscribe stream did not see the post-flip canary $flipValue — output: $subText"
}
}
# ---- HsbyFailoverCount — counter incremented by ≥ 1 ----
Write-Header "HsbyFailoverCount"
$failoverAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount"
if ($null -eq $failoverAfter) { $failoverAfter = 0 }
$counterOk = ($failoverAfter - $failoverBaseline) -ge 1
$results += [PSCustomObject]@{
Name = "HsbyFailoverCount"
Passed = $counterOk
Detail = if ($counterOk) {
"AbCip.HsbyFailoverCount went from $failoverBaseline$failoverAfter"
} else {
"AbCip.HsbyFailoverCount unchanged ($failoverBaseline$failoverAfter); expected at least 1 increment"
}
}
Write-Summary -Title "AB CIP HSBY failover e2e" -Results $results
if ($results | Where-Object { -not $_.Passed }) { exit 1 }