Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 93 additions & 22 deletions .github/workflows/ied-real-device-f4.yml
Original file line number Diff line number Diff line change
@@ -1,35 +1,44 @@
name: F4 Real IED Acceptance RC

# The field kit is intentionally generated for explicitly requested source
# revisions and packaging changes. Any protocol commit can use workflow_dispatch.
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- '.github/workflows/ied-real-device-f4.yml'
- 'apps/ied_simulator/field_acceptance/**'
- 'apps/ied_simulator/packaging/windows/**'
- 'apps/ied_simulator/test_f4_release_contract.py'
push:
branches:
- feat/ied-sim-ux-performance-foundation
branches: [main]
paths:
- 'apps/ied_simulator/**'
- 'embedded/**'
- 'tests/fixtures/scl/**'
- 'tools/static_ied_server.cpp'
- 'tools/static_file_service_backend.hpp'
- 'tools/static_file_service_backend_test.cpp'
- 'tools/file_transfer.cpp'
- 'tools/mms_*probe.cpp'
- 'tools/control_block_read_probe.cpp'
- 'tools/live_discover.cpp'
- 'tools/static_ied_server_iedsim/**'
- 'src/mms/**'
- 'include/ariec61850/mms/**'
- '.github/workflows/ied-real-device-f4.yml'
- 'apps/ied_simulator/field_acceptance/**'
- 'apps/ied_simulator/packaging/windows/**'
- 'apps/ied_simulator/test_f4_release_contract.py'

permissions:
contents: read

# Never label a synthetic pull_request merge SHA as the shipped build head.
# Pull-request CI checks out the actual requested source commit. Push/dispatch
# events use their own exact triggering commit.
env:
F4_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}

jobs:
protocol-gate:
name: Linux protocol gate
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.F4_SOURCE_SHA }}

- name: Verify F4 Windows packaging contract
run: python3 apps/ied_simulator/test_f4_release_contract.py

- name: Configure current IEC 61850 interoperability server
run: cmake -S tools/static_ied_server_iedsim -B build-f4-protocol -DCMAKE_BUILD_TYPE=Release
Expand All @@ -45,6 +54,28 @@ jobs:
runs-on: windows-2025
steps:
- uses: actions/checkout@v4
Comment thread
masarray marked this conversation as resolved.
with:
ref: ${{ env.F4_SOURCE_SHA }}

- name: Parse field-collector PowerShell before expensive Windows build
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$scripts = @(
'apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1',
'apps/ied_simulator/packaging/windows/verify-release.ps1'
)
foreach ($script in $scripts) {
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path -LiteralPath $script).Path,
[ref]$tokens, [ref]$errors) | Out-Null
if ($errors.Count -gt 0) {
throw "Invalid PowerShell syntax in ${script}: $($errors | Out-String)"
}
}
Write-Host 'F4_POWERSHELL_PARSE_PASS'

- name: Install Qt
uses: jurplel/install-qt-action@v4
Expand All @@ -53,9 +84,30 @@ jobs:
arch: 'win64_msvc2022_64'
cache: true

- name: Install NSIS
- name: Install and verify NSIS
shell: pwsh
run: choco install nsis --yes --no-progress
run: |
$ErrorActionPreference = 'Stop'
$candidates = @(
(Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe'),
(Join-Path ${env:ProgramFiles} 'NSIS\makensis.exe'),
'C:\ProgramData\chocolatey\bin\makensis.exe'
)
$compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not $compiler) {
for ($attempt = 1; $attempt -le 2; $attempt++) {
choco install nsis --yes --no-progress
$exitStatus = $LASTEXITCODE
$compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if ($compiler) { break }
Write-Warning "NSIS installation failed (attempt $attempt/2, Chocolatey exit $exitStatus)"
if ($attempt -lt 2) { Start-Sleep -Seconds 12 }
}
}
if (-not $compiler) { throw 'NSIS unavailable; abort field-kit build before compilation.' }
"MAKENSIS_PATH=$compiler" >> $env:GITHUB_ENV
& $compiler /VERSION
if ($LASTEXITCODE -ne 0) { throw 'makensis version preflight failed.' }

- name: Configure Workbench release build
shell: pwsh
Expand Down Expand Up @@ -125,6 +177,15 @@ jobs:
Copy-Item 'build-f4-windows\Release\arstack_ied_simulator.exe' $stage
Copy-Item 'build-f4-windows\Release\ariec61850_ied_simulator_server.exe' $stage
windeployqt --release --qmldir apps/ied_simulator/qml --dir $stage (Join-Path $stage 'arstack_ied_simulator.exe')
$sourceSha = (& git rev-parse --verify HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or $sourceSha -notmatch '^[0-9a-f]{40}$' -or
$sourceSha -cne $env:F4_SOURCE_SHA) {
throw "F4 source mismatch: checkout=$sourceSha action=$env:F4_SOURCE_SHA"
}
[System.IO.File]::WriteAllText(
(Join-Path $stage 'BUILD_HEAD.txt'),
($sourceSha + [System.Environment]::NewLine),
[System.Text.Encoding]::ASCII)

- name: Build latest portable ZIP and NSIS installer
shell: pwsh
Expand All @@ -136,9 +197,12 @@ jobs:
$zip = Join-Path $release 'ARStack-IEC61850-Workbench-portable-win64.zip'
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -Force
$installer = Join-Path $release 'ARStack-IEC61850-Workbench-Setup-win64.exe'
$makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe'
if (-not (Test-Path -LiteralPath $makensis -PathType Leaf)) { throw "makensis not found: $makensis" }
& $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi'
$makensis = $env:MAKENSIS_PATH
$icon = (Resolve-Path 'assets\favicon.ico').Path
if (-not $makensis -or -not (Test-Path -LiteralPath $makensis -PathType Leaf)) {
throw 'NSIS preflight did not supply a usable makensis.exe'
}
& $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" "/DAPP_ICON=$icon" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi'
if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }

- name: Verify staged and installed release
Expand All @@ -148,6 +212,8 @@ jobs:
-StageDir ./release-stage/ARStack-IEC61850-Workbench
-BuildDir ./build-f4-windows/Release
-InstallerPath ./release-artifacts/ARStack-IEC61850-Workbench-Setup-win64.exe
-PortablePath ./release-artifacts/ARStack-IEC61850-Workbench-portable-win64.zip
-ExpectedSourceSha ${{ env.F4_SOURCE_SHA }}
-FixturePath ./tests/fixtures/scl/client-workbench.scd

- name: Assemble F4 external-acceptance kit
Expand All @@ -166,7 +232,12 @@ jobs:
Copy-Item 'apps\ied_simulator\field_acceptance\ACCEPTANCE_RESULT_TEMPLATE.md' $kit
Copy-Item 'apps\ied_simulator\field_acceptance\collect-f4-evidence.ps1' $kit
Copy-Item 'tests\fixtures\scl\interop-golden-synthetic.cid' $fixtures
Set-Content -LiteralPath (Join-Path $kit 'BUILD_HEAD.txt') -Value $env:GITHUB_SHA -Encoding ascii
$headPath = Join-Path $PWD 'release-stage\ARStack-IEC61850-Workbench\BUILD_HEAD.txt'
$stageHead = (Get-Content -LiteralPath $headPath -Raw).Trim()
if ($stageHead -cne $env:F4_SOURCE_SHA) {
throw "F4 kit source mismatch: staged=$stageHead expected=$env:F4_SOURCE_SHA"
}
Copy-Item -LiteralPath $headPath -Destination (Join-Path $kit 'BUILD_HEAD.txt')

$server = Get-ChildItem -Path 'build-f4-windows' -Recurse -File -Filter 'ariec61850_ied_simulator_server.exe' |
Where-Object { $_.FullName -match '[\\/]Release[\\/]' } |
Expand All @@ -193,7 +264,7 @@ jobs:

$manifest = [ordered]@{
schemaVersion = 'arstack-f4-field-kit-v2-interop-golden'
headSha = $env:GITHUB_SHA
headSha = $env:F4_SOURCE_SHA
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
syntheticGolden = 'passed_in_windows_preflight'
externalAcceptance = 'pending_external_client'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# F4 External Acceptance Result

Build head: `______________________________`
Build head (kit `BUILD_HEAD.txt`): `______________________________`

Installed/portable Workbench `BUILD_HEAD.txt` (must match for ARStack -> IED): `______________________________`

Package provenance: [ ] verified same 40-char SHA [ ] blocked/missing evidence

Target-bound TCP frames (from `evidence.json`): `__________`

GOOSE PCAP and visual side-by-side evidence are separate requirements; this MMS
collector alone cannot mark complete physical IED or GUI acceptance.

Date/time: `______________________________`

Expand Down
11 changes: 9 additions & 2 deletions apps/ied_simulator/field_acceptance/F4_REAL_IED_ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Required evidence:

## Windows field kit

The **F4 Real IED Acceptance RC** workflow can be dispatched for any chosen source revision. Changes to this F4 workflow, its field kit, or the Windows packaging contract also run exact-PR-head CI and post-merge main packaging. Do not use a field kit whose embedded `BUILD_HEAD.txt` differs from the exact source revision being accepted.

The F4 workflow produces a Windows artifact containing:

- current Workbench installer and portable ZIP;
Expand All @@ -47,7 +49,9 @@ The F4 workflow produces a Windows artifact containing:

### Capture preparation

Install Wireshark/Npcap so `dumpcap.exe` is available. List capture interfaces:
Install Wireshark/Npcap with both `dumpcap.exe` and `tshark.exe`. The capture is intentionally **bound to the exact target IPv4 address and MMS port**; unrelated traffic on TCP/102 does not qualify as acceptance. The collector refuses a missing/invalid kit SHA, missing tshark, invalid NIC index, zero observed target packets, or an installed/extracted Workbench whose SHA differs from the kit.

List capture interfaces:

```powershell
& "C:\Program Files\Wireshark\dumpcap.exe" -D
Expand All @@ -72,10 +76,13 @@ Example, ARStack testing a real IED:
-Mode ARStackToIED `
-TargetIp 192.168.1.10 `
-InterfaceId 4 `
-WorkbenchDirectory 'C:\Program Files\ARStack IEC 61850 Workbench' `
-DurationSeconds 180
```

During the capture, execute the acceptance steps above. The collector writes a timestamped folder containing the PCAPNG, environment/preflight information, SHA-256, optional tshark packet/reset counts, and a manual-result template.
For the real-IED lane, `-WorkbenchDirectory` is **mandatory** and may reference either the installed app or the extracted portable ZIP root (both contain `BUILD_HEAD.txt` from the same release). The directory must contain `arstack_ied_simulator.exe`. The external-client-to-ARStack lane may optionally supply it, but the collector cannot independently prove which server process is active: attach server diagnostics to close that gap.

During capture, perform the authorized steps above. The collector writes a timestamped folder containing `evidence.json`, a target-specific PCAPNG, exact source SHA, TCP preflight, SHA-256 and tshark-verified target packet/reset counts, and a **manual** result template. Capturing one TCP packet is not proof of MMS association, report reception, or hardware interoperability; inspect service-level frames and screenshots separately. Missing network visibility is **INCOMPLETE**, never PASS.

## Pass / close rule

Expand Down
88 changes: 71 additions & 17 deletions apps/ied_simulator/field_acceptance/collect-f4-evidence.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ param(
[ValidateRange(30, 1800)]
[int]$DurationSeconds = 180,

[string]$OutputRoot = (Join-Path $PWD 'f4-evidence')
[string]$OutputRoot = (Join-Path $PWD 'f4-evidence'),

# Mandatory for Workbench -> IED: provenance of the ACTUAL installed
# or extracted application, not only the field-kit wrapper.
[string]$WorkbenchDirectory = ''
)

$ErrorActionPreference = 'Stop'
Expand All @@ -41,8 +45,50 @@ if ($Port -lt 1 -or $Port -gt 65535) {
throw 'Port must be in the range 1..65535.'
}

# A capture host expression and Wireshark display filter require a literal
# IPv4 endpoint. Never pass arbitrary user text through BPF/tshark filters.
[System.Net.IPAddress]$parsedIp = $null
if (-not [System.Net.IPAddress]::TryParse($TargetIp, [ref]$parsedIp) -or
$parsedIp.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork -or
$parsedIp.Equals([System.Net.IPAddress]::Any) -or
$parsedIp.Equals([System.Net.IPAddress]::Broadcast)) {
throw 'TargetIp must be a concrete IPv4 address; DNS/filter expressions are not accepted.'
}
$TargetIp = $parsedIp.ToString()

$kitHeadFile = Join-Path $PSScriptRoot 'BUILD_HEAD.txt'
if (-not (Test-Path -LiteralPath $kitHeadFile -PathType Leaf)) {
throw 'F4 kit BUILD_HEAD.txt is missing; package provenance cannot be established.'
}
$buildHead = (Get-Content -LiteralPath $kitHeadFile -Raw).Trim()
if ($buildHead -cnotmatch '^[0-9a-f]{40}$') {
throw 'F4 kit BUILD_HEAD.txt is not an exact 40-character source SHA.'
}
$workbenchBuildHead = $null
$workbenchVerified = $false
if ($Mode -eq 'ARStackToIED' -and [string]::IsNullOrWhiteSpace($WorkbenchDirectory)) {
throw 'ARStackToIED requires -WorkbenchDirectory pointing to the installed/extracted Workbench.'
}
if (-not [string]::IsNullOrWhiteSpace($WorkbenchDirectory)) {
$verifiedDirectory = (Resolve-Path -LiteralPath $WorkbenchDirectory -ErrorAction Stop).Path
$app = Join-Path $verifiedDirectory 'arstack_ied_simulator.exe'
$installedHeadFile = Join-Path $verifiedDirectory 'BUILD_HEAD.txt'
if (-not (Test-Path -LiteralPath $app -PathType Leaf) -or
-not (Test-Path -LiteralPath $installedHeadFile -PathType Leaf)) {
throw 'Workbench executable or BUILD_HEAD.txt missing in selected installed/portable directory.'
}
$workbenchBuildHead = (Get-Content -LiteralPath $installedHeadFile -Raw).Trim()
if ($workbenchBuildHead -cne $buildHead) {
throw "Field kit/Workbench source SHA mismatch: kit=$buildHead app=$workbenchBuildHead"
}
$workbenchVerified = $true
}

$dumpcap = Resolve-Dumpcap
$tshark = Resolve-Tshark
if (-not $tshark) {
throw 'tshark.exe required to verify target IPv4 MMS packet evidence (install Wireshark).'
}
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmssZ')
$evidenceDir = Join-Path $OutputRoot ("F4-{0}-{1}" -f $Mode, $stamp)
New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null
Expand All @@ -57,13 +103,14 @@ try {
Write-Host "Duration: $DurationSeconds seconds"

$interfacesPath = Join-Path $evidenceDir 'capture-interfaces.txt'
& $dumpcap -D | Tee-Object -FilePath $interfacesPath | Out-Host

$buildHead = 'unknown'
$headFile = Join-Path $PSScriptRoot 'BUILD_HEAD.txt'
if (Test-Path -LiteralPath $headFile -PathType Leaf) {
$buildHead = (Get-Content -LiteralPath $headFile -Raw).Trim()
$interfaces = @(& $dumpcap -D)
if ($LASTEXITCODE -ne 0) { throw 'dumpcap interface enumeration failed.' }
$interfaces | Set-Content -LiteralPath $interfacesPath -Encoding UTF8
$interfaces | Out-Host
if (-not ($interfaces | Where-Object { $_ -match "^\s*$InterfaceId\." })) {
throw "Capture interface id $InterfaceId is not present in dumpcap -D."
}
$buildHead | Set-Content -LiteralPath (Join-Path $evidenceDir 'workbench_build_sha.txt') -Encoding ascii

$preflight = Test-NetConnection -ComputerName $TargetIp -Port $Port -WarningAction SilentlyContinue
$preflight | Format-List * | Out-String | Set-Content -LiteralPath (Join-Path $evidenceDir 'tcp-preflight.txt')
Expand All @@ -72,7 +119,7 @@ try {
}

$pcapPath = Join-Path $evidenceDir 'mms-session.pcapng'
$filter = "tcp port $Port"
$filter = "host $TargetIp and tcp port $Port"
Write-Host "Starting dumpcap. Execute the F4 acceptance sequence now."
Write-Host "Capture filter: $filter"

Expand All @@ -92,18 +139,22 @@ try {
}

$pcapHash = (Get-FileHash -LiteralPath $pcapPath -Algorithm SHA256).Hash.ToLowerInvariant()
$packetCount = $null
$tcpResetCount = $null
if ($tshark) {
$packetLines = & $tshark -r $pcapPath -Y "tcp.port==$Port" -T fields -e frame.number 2>$null
$packetCount = @($packetLines | Where-Object { $_ -ne '' }).Count
$resetLines = & $tshark -r $pcapPath -Y "tcp.port==$Port && tcp.flags.reset==1" -T fields -e frame.number 2>$null
$tcpResetCount = @($resetLines | Where-Object { $_ -ne '' }).Count
}
$displayFilter = "ip.addr==$TargetIp && tcp.port==$Port"
$packetLines = @(& $tshark -r $pcapPath -Y $displayFilter -T fields -e frame.number 2>$null)
if ($LASTEXITCODE -ne 0) { throw 'tshark could not decode/verify target MMS capture.' }
$packetCount = @($packetLines | Where-Object { $_ -ne '' }).Count
$resetFilter = "$displayFilter && tcp.flags.reset==1"
$resetLines = @(& $tshark -r $pcapPath -Y $resetFilter -T fields -e frame.number 2>$null)
if ($LASTEXITCODE -ne 0) { throw 'tshark could not inspect target TCP reset evidence.' }
$tcpResetCount = @($resetLines | Where-Object { $_ -ne '' }).Count

$metadata = [ordered]@{
schemaVersion = 'arstack-f4-evidence-v1'
schemaVersion = 'arstack-f4-evidence-v2-target-bound'
buildHead = $buildHead
workbenchBuildHead = $workbenchBuildHead
workbenchProvenanceVerified = $workbenchVerified
captureFilter = $filter
targetPacketsVerified = ($packetCount -gt 0)
mode = $Mode
targetIp = $TargetIp
port = $Port
Expand All @@ -119,6 +170,9 @@ try {
externalAcceptance = 'pending_manual_review'
}
$metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $evidenceDir 'evidence.json') -Encoding UTF8
if ($packetCount -eq 0) {
throw 'No on-wire MMS TCP packets from the target were captured; verdict must be INCOMPLETE.'
}

$template = Join-Path $PSScriptRoot 'ACCEPTANCE_RESULT_TEMPLATE.md'
if (Test-Path -LiteralPath $template -PathType Leaf) {
Expand Down
Loading
Loading