diff --git a/scripts/validation/opcua-scripted-alarm-baseline/README.md b/scripts/validation/opcua-scripted-alarm-baseline/README.md new file mode 100644 index 00000000..3188ae6e --- /dev/null +++ b/scripts/validation/opcua-scripted-alarm-baseline/README.md @@ -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. diff --git a/scripts/validation/scadabridge-sdk-compatibility/Directory.Build.props b/scripts/validation/scadabridge-sdk-compatibility/Directory.Build.props new file mode 100644 index 00000000..4af490d1 --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/Directory.Build.props @@ -0,0 +1,9 @@ + + + + net10.0 + enableenable + true + false + + diff --git a/scripts/validation/scadabridge-sdk-compatibility/Directory.Packages.props b/scripts/validation/scadabridge-sdk-compatibility/Directory.Packages.props new file mode 100644 index 00000000..802827ef --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/Directory.Packages.props @@ -0,0 +1 @@ +false diff --git a/scripts/validation/scadabridge-sdk-compatibility/Probe.csproj b/scripts/validation/scadabridge-sdk-compatibility/Probe.csproj new file mode 100644 index 00000000..3aeca76a --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/Probe.csproj @@ -0,0 +1,9 @@ + + Exe + + + + + + + diff --git a/scripts/validation/scadabridge-sdk-compatibility/Program.cs b/scripts/validation/scadabridge-sdk-compatibility/Program.cs new file mode 100644 index 00000000..aa119432 --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/Program.cs @@ -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 "); +var endpoint = args[0]; +var mode = Enum.Parse(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(); +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.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 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 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(); + 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(); + 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; diff --git a/scripts/validation/scadabridge-sdk-compatibility/README.md b/scripts/validation/scadabridge-sdk-compatibility/README.md new file mode 100644 index 00000000..3b3eb4c5 --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/README.md @@ -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. diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-None.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-None.json new file mode 100644 index 00000000..24e67677 --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-None.json @@ -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" + ] +} diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-SignAndEncrypt.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-SignAndEncrypt.json new file mode 100644 index 00000000..36c5e57d --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/baseline-SignAndEncrypt.json @@ -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" + ] +} diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-None.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-None.json new file mode 100644 index 00000000..4634c6ab --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-None.json @@ -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" + ] +} diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-SignAndEncrypt.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-SignAndEncrypt.json new file mode 100644 index 00000000..738c13e2 --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/candidate-SignAndEncrypt.json @@ -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" + ] +} diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/receipt.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/receipt.json new file mode 100644 index 00000000..bc2522da --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/receipt.json @@ -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 + } + ] +} diff --git a/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/summary.json b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/summary.json new file mode 100644 index 00000000..0c329bc8 --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/evidence/2026-11-10/summary.json @@ -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 +} diff --git a/scripts/validation/scadabridge-sdk-compatibility/run.py b/scripts/validation/scadabridge-sdk-compatibility/run.py new file mode 100644 index 00000000..86ac0c0b --- /dev/null +++ b/scripts/validation/scadabridge-sdk-compatibility/run.py @@ -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())