test: record actual ScadaBridge consumer and baseline authorization gates

This commit is contained in:
Joseph Doherty
2026-11-10 13:17:13 -05:00
parent 8d75ac8c6c
commit ff44167926
13 changed files with 555 additions and 0 deletions
@@ -0,0 +1,35 @@
# Independent scripted-alarm scope baseline
This SDK 1.5.378.182-only source probe records the existing opaque-ID behavior
tracked by [#797](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/797).
It is deliberately outside the candidate solution and is not a runtime adapter.
Use a clean, separate checkout at original application commit
`83ed880e4471344deaf008da9bd028c7e170bdc7`. Confirm its package pins before
running. Copy **only** `ScriptedScopeProbe.cs` temporarily into that checkout's
`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/`, refusing to
overwrite an existing file. Run:
```sh
dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests \
-c Release --filter FullyQualifiedName~BaselineScriptedScopeProbe \
--logger trx --results-directory /absolute/fresh-baseline-evidence
```
Preserve the test exit and run the baseline checkout's
`scripts/ci/assert-not-all-skipped.sh` with `MIN_EXECUTED=1` against the fresh TRX.
Remove only the temporary probe you copied after the run, keeping the source
and TRX evidence here/outside the checkout. Verify baseline tracked files remain
unchanged. Never load this assembly or old SDK into the candidate process.
The real session has no application-wide roles. The production-valid opaque
scripted ID is denied by an Equipment grant, routes nothing, and is then allowed
by an explicit Cluster grant. A passing probe pins **both** outcomes; it does not
claim narrow equipment authorization works. The independent candidate counterpart
is `Sdk2OpaqueScriptedScopeBaselineTests`.
Do not make the ID slash-qualified to get a positive result: the current UI
rejects `/`, and that would hide the production scope-resolution defect. A future
fix requires reviewed ownership/scope binding without changing opaque IDs or
widening permissions. This upgrade preserves the baseline denial and links the
separate defect.
@@ -0,0 +1,9 @@
<Project>
<!-- A separate consumer process: never inherit this repository's SDK2 package policy. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
@@ -0,0 +1 @@
<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
<ItemGroup>
<ProjectReference Include="$(ScadaBridgeSourceRoot)/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj" />
</ItemGroup>
<Target Name="RequireConsumerSource" BeforeTargets="CollectPackageReferences">
<Error Condition="'$(ScadaBridgeSourceRoot)' == ''" Text="Pass -p:ScadaBridgeSourceRoot=/absolute/ScadaBridge; this probe must use the actual consumer and its own pinned SDK." />
</Target>
</Project>
@@ -0,0 +1,129 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
if (args.Length != 3) throw new ArgumentException("Usage: Probe <endpoint> <None|SignAndEncrypt> <new-result-directory>");
var endpoint = args[0];
var mode = Enum.Parse<OpcUaSecurityMode>(args[1]);
var root = Path.GetFullPath(args[2]);
if (Directory.Exists(root)) throw new IOException("Refusing to reuse an evidence/PKI directory.");
Directory.CreateDirectory(root);
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var ct = deadline.Token;
var cases = new List<object>();
var failures = 0;
var options = new OpcUaGlobalOptions
{
ApplicationName = "ScadaBridge-SDK2-compatibility",
TrustedIssuerStorePath = Path.Combine(root, "pki", "issuers"),
TrustedPeerStorePath = Path.Combine(root, "pki", "trusted"),
RejectedCertificateStorePath = Path.Combine(root, "pki", "rejected"),
};
// Populate only this probe's trust list; never change the consumer's existing stores.
var trustSource = Environment.GetEnvironmentVariable("SCADABRIDGE_PROBE_SERVER_CERTS");
if (!string.IsNullOrEmpty(trustSource))
{
var destination = Path.Combine(options.TrustedPeerStorePath, "certs");
Directory.CreateDirectory(destination);
foreach (var cert in Directory.GetFiles(trustSource, "*.der")) File.Copy(cert, Path.Combine(destination, Path.GetFileName(cert)));
}
var factory = new RealOpcUaClientFactory(options, NullLoggerFactory.Instance);
await using var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
var config = new OpcUaEndpointConfig
{
EndpointUrl = endpoint, SecurityMode = mode, AutoAcceptUntrustedCerts = false,
PublishingIntervalMs = 100, SamplingIntervalMs = 100,
UserIdentity = new OpcUaUserIdentityConfig
{
TokenType = OpcUaUserTokenType.UsernamePassword, Username = "apikey",
Password = Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing task credential"),
},
};
static void Require(bool value, string reason) { if (!value) throw new InvalidOperationException(reason); }
async Task Check(string name, Func<Task> action)
{
try { await action(); cases.Add(new { name, status = "passed" }); }
catch (Exception e) { failures++; cases.Add(new { name, status = "failed", error = e.ToString() }); }
}
async Task Wait(Func<bool> predicate)
{
for (var i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, ct); }
Require(false, "No expected consumer notification within 10 seconds.");
}
const string raw = "nsu=https://zb.com/otopcua/raw;s=Matrix/dev/";
const string uns = "nsu=https://zb.com/otopcua/uns;s=Eq/";
await Check("actual-adapter-connect-api-key", () => adapter.ConnectAsync(OpcUaEndpointConfigSerializer.ToFlatDict(config), ct));
if (failures == 0)
{
foreach (var prefix in new[] { raw, uns })
{
await Check($"scalar-round-trip-{prefix}", async () =>
{
var write = await adapter.WriteAsync(prefix + "Scalar", 412, ct);
Require(write.Success, write.ErrorMessage ?? "Scalar write denied");
var read = await adapter.ReadAsync(prefix + "Scalar", ct);
Require(read.Success && read.Value?.Value is int i && i == 412, "Scalar value/type changed");
});
await Check($"array-round-trip-{prefix}", async () =>
{
var write = await adapter.WriteAsync(prefix + "Vector", new[] { 31, 32, 33 }, ct);
Require(write.Success, write.ErrorMessage ?? "Array write denied");
var read = await adapter.ReadAsync(prefix + "Vector", ct);
Require(read.Success && read.Value?.Value is int[] a && a.SequenceEqual(new[] { 31, 32, 33 }), "Array value/type changed");
});
}
await Check("actual-adapter-durable-browse", async () =>
{
var page = await adapter.BrowseChildrenAsync("nsu=https://zb.com/otopcua/raw;s=Matrix/dev", cancellationToken: ct);
Require(page.Children.Count(x => x.DisplayName is "Scalar" or "Vector") == 2, "Missing durable raw variable children");
Require(page.Children.Any(x => x.DisplayName == "Alarm"), "Missing native alarm under its raw parent");
var projected = await adapter.BrowseChildrenAsync("nsu=https://zb.com/otopcua/uns;s=Eq", cancellationToken: ct);
Require(projected.Children.Any(x => x.DisplayName == "Vector"), "Missing UNS variable child");
});
await Check("actual-adapter-monitor-quality-time", async () =>
{
var values = new ConcurrentQueue<TagValue>();
var handle = await adapter.SubscribeAsync(raw + "Scalar", (_, value) => values.Enqueue(value), ct);
Require((await adapter.WriteAsync(raw + "Scalar", 713, ct)).Success, "Monitored write denied");
await Wait(() => values.Any(x => x.Value is int i && i == 713));
var actual = values.Last(x => x.Value is int i && i == 713);
Require(actual.Timestamp > new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), "Missing source timestamp");
Require(actual.Quality.ToString() == "Good", "Quality was not Good");
await adapter.UnsubscribeAsync(handle, ct);
});
await Check("actual-adapter-alarm-filter-refresh", async () =>
{
var alarms = new ConcurrentQueue<NativeAlarmTransition>();
var handle = await adapter.SubscribeAlarmsAsync("i=2253", null, alarms.Enqueue, ct);
await Wait(() => alarms.Any(x => x.Kind == AlarmTransitionKind.SnapshotComplete)
&& alarms.Any(x => x.Message == "matrix-alarm" && x.SourceReference.Contains("Alarm", StringComparison.Ordinal)));
await adapter.UnsubscribeAlarmsAsync(handle, ct);
});
await Check("actual-adapter-disconnect-reconnect", async () =>
{
await adapter.DisconnectAsync(ct);
await adapter.ConnectAsync(OpcUaEndpointConfigSerializer.ToFlatDict(config), ct);
Require((await adapter.ReadAsync(raw + "Scalar", ct)).Success, "Consumer failed after reconnect");
await adapter.DisconnectAsync(ct);
});
}
var sdkAssembly = typeof(Opc.Ua.NodeId).Assembly;
var result = new
{
consumer = typeof(OpcUaDataConnection).Assembly.GetName().Version?.ToString(),
sdk = FileVersionInfo.GetVersionInfo(sdkAssembly.Location).ProductVersion,
process = Environment.ProcessId, mode = mode.ToString(), autoAccept = false,
endpoint, cases, failures,
notImplementedByConsumer = new[] { "HistoryRead/CP", "Part9 acknowledgement methods" },
};
File.WriteAllText(Path.Combine(root, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine($"Consumer probe: {cases.Count - failures} passed, {failures} failed; {Path.Combine(root, "result.json")}");
return failures == 0 ? 0 : 1;
@@ -0,0 +1,46 @@
# Actual ScadaBridge consumer compatibility
This isolated process references the real ScadaBridge `OpcUaDataConnection` and
`RealOpcUaClient`, with their own dependency graph. It never loads the candidate
SDK into that process. The verified consumer source is
`0e89966da670e289bd875166295bb0e85d8cf8b2`, pinned to SDK 1.5.378.106.
Build with an explicit clean consumer checkout:
```sh
dotnet build scripts/validation/scadabridge-sdk-compatibility/Probe.csproj \
-c Release -p:ScadaBridgeSourceRoot=/absolute/ScadaBridge
```
First build the separate application hosts with the parent
`scripts/e2e/test-opcua-sdk-compatibility.ps1` harness. Then choose a new output
directory and pass its original and candidate `ProcessHost.dll` paths:
```sh
python3 scripts/validation/scadabridge-sdk-compatibility/run.py \
--baseline-host /absolute/matrix/build-baseline/bin/Release/net10.0/ProcessHost.dll \
--candidate-host /absolute/matrix/build-candidate/bin/Release/net10.0/ProcessHost.dll \
--probe scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll \
--results /absolute/fresh-consumer-evidence
```
The helper runs four separate consumer/server pairs: each application version
with None and SignAndEncrypt. None is enabled only in its own test fixture;
the ordinary compatibility host remains strict SignAndEncrypt. The task key is
generated per process pair, issuer/trusted/rejected stores are task-local, and
server public certificates are explicitly copied. Autoaccept remains false.
Never commit an entire output directory: it contains the fixture's private PKI.
The consumed subset covers API-key activation, raw/UNS scalar and Int32[]
roundtrips, durable hierarchical browse including the native alarm, data quality
and timestamps, alarm filter/ConditionRefresh and disconnect/reconnect. History
and Part 9 acknowledgment are not exposed by the inspected consumer interfaces;
the parent Shared/CLI matrix covers those application services instead.
ScadaBridge#121 is a reproduced preexisting limitation: SignAndEncrypt fails
against **both** original and candidate servers because the consumer does not
configure an application certificate. A matching baseline failure is evidence,
not a secure-channel pass. The runner retains the nonzero result and each actual
failure; it does not loosen trust or change expected results to return success.
The current None subset and the missing secure identity must be reported
separately. No ScadaBridge production source or user PKI is modified.
@@ -0,0 +1,51 @@
{
"consumer": "1.0.0.0",
"sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
"process": 93934,
"mode": "None",
"autoAccept": false,
"endpoint": "opc.tcp://127.0.0.1:51314/OtOpcUa",
"cases": [
{
"name": "actual-adapter-connect-api-key",
"status": "passed"
},
{
"name": "scalar-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
"status": "passed"
},
{
"name": "array-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
"status": "passed"
},
{
"name": "scalar-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
"status": "passed"
},
{
"name": "array-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
"status": "passed"
},
{
"name": "actual-adapter-durable-browse",
"status": "passed"
},
{
"name": "actual-adapter-monitor-quality-time",
"status": "passed"
},
{
"name": "actual-adapter-alarm-filter-refresh",
"status": "passed"
},
{
"name": "actual-adapter-disconnect-reconnect",
"status": "passed"
}
],
"failures": 0,
"notImplementedByConsumer": [
"HistoryRead/CP",
"Part9 acknowledgement methods"
]
}
@@ -0,0 +1,20 @@
{
"consumer": "1.0.0.0",
"sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
"process": 93939,
"mode": "SignAndEncrypt",
"autoAccept": false,
"endpoint": "opc.tcp://127.0.0.1:51358/OtOpcUa",
"cases": [
{
"name": "actual-adapter-connect-api-key",
"status": "failed",
"error": "ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.\n[80890000] 'ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.'"
}
],
"failures": 1,
"notImplementedByConsumer": [
"HistoryRead/CP",
"Part9 acknowledgement methods"
]
}
@@ -0,0 +1,51 @@
{
"consumer": "1.0.0.0",
"sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
"process": 93941,
"mode": "None",
"autoAccept": false,
"endpoint": "opc.tcp://127.0.0.1:51387/OtOpcUa",
"cases": [
{
"name": "actual-adapter-connect-api-key",
"status": "passed"
},
{
"name": "scalar-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
"status": "passed"
},
{
"name": "array-round-trip-nsu=https://zb.com/otopcua/raw;s=Matrix/dev/",
"status": "passed"
},
{
"name": "scalar-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
"status": "passed"
},
{
"name": "array-round-trip-nsu=https://zb.com/otopcua/uns;s=Eq/",
"status": "passed"
},
{
"name": "actual-adapter-durable-browse",
"status": "passed"
},
{
"name": "actual-adapter-monitor-quality-time",
"status": "passed"
},
{
"name": "actual-adapter-alarm-filter-refresh",
"status": "passed"
},
{
"name": "actual-adapter-disconnect-reconnect",
"status": "passed"
}
],
"failures": 0,
"notImplementedByConsumer": [
"HistoryRead/CP",
"Part9 acknowledgement methods"
]
}
@@ -0,0 +1,20 @@
{
"consumer": "1.0.0.0",
"sdk": "1.5.378.106+99044fdbb638087d62f1e16580e9f73bfda364e2",
"process": 93943,
"mode": "SignAndEncrypt",
"autoAccept": false,
"endpoint": "opc.tcp://127.0.0.1:51455/OtOpcUa",
"cases": [
{
"name": "actual-adapter-connect-api-key",
"status": "failed",
"error": "ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.\n[80890000] 'ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.'"
}
],
"failures": 1,
"notImplementedByConsumer": [
"HistoryRead/CP",
"Part9 acknowledgement methods"
]
}
@@ -0,0 +1,98 @@
{
"artifacts": {
"baseline_host": {
"path": "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-baseline/bin/Release/net10.0/ProcessHost.dll",
"sha256": "841f83aeb3a32d49c06518a98963743d720015bba41ae3138649126ce3a6af1c"
},
"candidate_host": {
"path": "/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-candidate/bin/Release/net10.0/ProcessHost.dll",
"sha256": "e025fc21ac3559c3796ec639f0ef7b9c3026ccbbdaa4783e14b8930eae3a2043"
},
"probe": {
"path": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
"sha256": "913ef3e35ffac8a54567e5d4df6f934572043249eaa6a4e0f829efd07b9012d7"
}
},
"legs": [
{
"server": "baseline",
"mode": "None",
"serverCommand": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-baseline/bin/Release/net10.0/ProcessHost.dll",
"server",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-None/server"
],
"command": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
"opc.tcp://127.0.0.1:51314/OtOpcUa",
"None",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-None/consumer"
],
"exit": 0,
"result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-None/consumer/result.json",
"serverExit": 0
},
{
"server": "baseline",
"mode": "SignAndEncrypt",
"serverCommand": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-baseline/bin/Release/net10.0/ProcessHost.dll",
"server",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-SignAndEncrypt/server"
],
"command": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
"opc.tcp://127.0.0.1:51358/OtOpcUa",
"SignAndEncrypt",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-SignAndEncrypt/consumer"
],
"exit": 1,
"result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/baseline-SignAndEncrypt/consumer/result.json",
"serverExit": 0
},
{
"server": "candidate",
"mode": "None",
"serverCommand": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-candidate/bin/Release/net10.0/ProcessHost.dll",
"server",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-None/server"
],
"command": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
"opc.tcp://127.0.0.1:51387/OtOpcUa",
"None",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-None/consumer"
],
"exit": 0,
"result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-None/consumer/result.json",
"serverExit": 0
},
{
"server": "candidate",
"mode": "SignAndEncrypt",
"serverCommand": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua2-cross-version/OtOpcUa/artifacts/cross-version/run-10/build-candidate/bin/Release/net10.0/ProcessHost.dll",
"server",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-SignAndEncrypt/server"
],
"command": [
"dotnet",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/scripts/validation/scadabridge-sdk-compatibility/bin/Release/net10.0/Probe.dll",
"opc.tcp://127.0.0.1:51455/OtOpcUa",
"SignAndEncrypt",
"/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-SignAndEncrypt/consumer"
],
"exit": 1,
"result": "/Users/dohertj2/.codex/worktrees/opcua-sdk-2-upgrade/OtOpcUa/artifacts/opcua2/88ee30e0/scadabridge-final-matrix/candidate-SignAndEncrypt/consumer/result.json",
"serverExit": 0
}
]
}
@@ -0,0 +1,11 @@
{
"consumerSource": "0e89966da670e289bd875166295bb0e85d8cf8b2",
"consumerSdk": "1.5.378.106",
"baselineServerSdk": "1.5.378.182",
"candidateServerSdk": "2.0.1",
"noneCasesPassed": 18,
"secureConnectionFailures": 2,
"samePreexistingFailure": "BadConfigurationError: ApplicationCertificate not configured",
"issue": "https://gitea.dohertylan.com/dohertj2/ScadaBridge/issues/121",
"releaseApproval": false
}
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Run the real ScadaBridge consumer in separate processes against both application versions."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import secrets
import subprocess
import time
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--baseline-host", type=Path, required=True)
parser.add_argument("--candidate-host", type=Path, required=True)
parser.add_argument("--probe", type=Path, required=True)
parser.add_argument("--results", type=Path, required=True)
args = parser.parse_args()
root = args.results.resolve()
root.mkdir(parents=True, exist_ok=False)
artifacts = {name: path.resolve() for name, path in vars(args).items() if name != "results"}
for path in artifacts.values():
if not path.is_file():
raise FileNotFoundError(path)
receipt = {
"artifacts": {name: {"path": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
for name, path in artifacts.items()},
"legs": [],
}
for version in ("baseline", "candidate"):
for mode in ("None", "SignAndEncrypt"):
leg = root / f"{version}-{mode}"
leg.mkdir()
server_root = leg / "server"
env = os.environ.copy()
env["OPCUA_MATRIX_PASSWORD"] = "otua_matrix_" + secrets.token_urlsafe(32)
if mode == "None":
env["OPCUA_MATRIX_SERVER_SECURITY"] = "none"
else:
env.pop("OPCUA_MATRIX_SERVER_SECURITY", None)
server_command = ["dotnet", str(artifacts[f"{version}_host"]), "server", str(server_root)]
result = {"server": version, "mode": mode, "serverCommand": server_command}
with (leg / "server.log").open("w") as server_log:
server = subprocess.Popen(server_command, env=env, stdout=server_log, stderr=subprocess.STDOUT)
try:
until = time.monotonic() + 40
while not (server_root / "endpoint.txt").is_file():
if server.poll() is not None or time.monotonic() >= until:
raise RuntimeError(f"Server failed to become ready; inspect {leg / 'server.log'}")
time.sleep(0.1)
endpoint = (server_root / "endpoint.txt").read_text().strip()
env["SCADABRIDGE_PROBE_SERVER_CERTS"] = str(server_root / "server-pki" / "own" / "certs")
command = ["dotnet", str(artifacts["probe"]), endpoint, mode, str(leg / "consumer")]
with (leg / "consumer.log").open("w") as log:
probe = subprocess.run(command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=150)
result.update(command=command, exit=probe.returncode,
result=str(leg / "consumer" / "result.json"))
finally:
server_root.mkdir(exist_ok=True)
(server_root / "stop").touch()
try:
server.wait(timeout=15)
except subprocess.TimeoutExpired:
server.terminate()
server.wait(timeout=10)
result["serverExit"] = server.returncode
receipt["legs"].append(result)
(root / "receipt.json").write_text(json.dumps(receipt, indent=2))
# A pre-existing consumer limitation is evidence to investigate, never converted into a passing leg.
return int(any(leg["exit"] != 0 or leg["serverExit"] != 0 for leg in receipt["legs"]))
if __name__ == "__main__":
raise SystemExit(main())