Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c794ae33a5 |
@@ -126,6 +126,7 @@
|
|||||||
provider that breaks the "managed only" rule, which is why it is opt-in per instance. -->
|
provider that breaks the "managed only" rule, which is why it is opt-in per instance. -->
|
||||||
<PackageVersion Include="System.Data.Odbc" Version="10.0.14" />
|
<PackageVersion Include="System.Data.Odbc" Version="10.0.14" />
|
||||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||||
|
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" Version="2.0.1" />
|
||||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
|
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
|
||||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||||
hard-coded uint constants against. Referenced by that TEST project only — the drivers
|
hard-coded uint constants against. Referenced by that TEST project only — the drivers
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
#Requires -Version 7.0
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Runs the production application SDK compatibility matrix in isolated processes.
|
|
||||||
.DESCRIPTION
|
|
||||||
Uses exact baseline host/client publish artifacts and builds the candidate from a
|
|
||||||
separate checkout. Both SDK versions remain in separate processes. Evidence,
|
|
||||||
artifact SHA-256 hashes, negotiated policies and individual results are retained.
|
|
||||||
The output directory must not exist. No fleet endpoint or store is used.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)][string]$BaselineRoot,
|
|
||||||
[Parameter(Mandatory)][string]$BaselineArtifacts,
|
|
||||||
[Parameter(Mandatory)][string]$CandidateRoot,
|
|
||||||
[Parameter(Mandatory)][string]$OutputDirectory,
|
|
||||||
[string]$ContainerImage = 'mcr.microsoft.com/dotnet/sdk:10.0.103-noble'
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$runner = Join-Path $PSScriptRoot '../validation/opcua-sdk-compatibility/run.py'
|
|
||||||
& python3 $runner --baseline-root $BaselineRoot --baseline-artifacts $BaselineArtifacts --candidate-root $CandidateRoot --output $OutputDirectory --container-image $ContainerImage
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# HistorianGateway consumer TLS migration — issue 131
|
|
||||||
|
|
||||||
`consumer-1.0.0.patch` is a reviewable consumer change, kept separate from the
|
|
||||||
buildable application in this checkout. Real paired Client/Contracts 1.0.0
|
|
||||||
packages are now published and the isolated owning-project proof passed (RESULTS.md).
|
|
||||||
The application in this checkout remains pinned to published 0.4.0 until the patch
|
|
||||||
is applied by the coordinator.
|
|
||||||
|
|
||||||
The patch changes both central package pins to 1.0.0 and maps the new option as:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
|
|
||||||
```
|
|
||||||
|
|
||||||
The old inverse-mapping and obsolete h2c-history comments are removed. The existing
|
|
||||||
h2c construction test now includes a stale opt-out as well as a stale CA path.
|
|
||||||
Three new bounded local wire tests verify behavior through the real adapter and
|
|
||||||
published client API, without reflection or replacing the transport handler:
|
|
||||||
|
|
||||||
- TLS with normal defaults rejects a self-signed server before the metadata RPC.
|
|
||||||
- Explicit opt-out permits that self-signed server shape and executes one RPC.
|
|
||||||
- Plaintext with stale opt-out and CA settings executes one RPC, proving TLS-only
|
|
||||||
settings were not forwarded to the client's validating options.
|
|
||||||
|
|
||||||
The local server uses an ephemeral loopback port and a fresh short-lived
|
|
||||||
self-signed certificate. RPC completion is bounded to 15 seconds; the consumer
|
|
||||||
call timeout is two seconds. No gateway, deployment, or global trust store is used.
|
|
||||||
|
|
||||||
Before applying the patch, query the live Gitea NuGet version indexes for BOTH
|
|
||||||
packages and require actual stable 1.0.0. Source `main` having the new API is not a
|
|
||||||
package release. At initial inspection, both feed indexes listed exactly
|
|
||||||
0.4.0, 0.3.0, 0.2.0, 0.1.0, and the new API was only in Unreleased at HG commit
|
|
||||||
`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git apply --check scripts/validation/historiangw-131/consumer-1.0.0.patch
|
|
||||||
git apply scripts/validation/historiangw-131/consumer-1.0.0.patch
|
|
||||||
# From the consumer checkout, using a fresh run/results directory:
|
|
||||||
hg131_results=/absolute/fresh/results
|
|
||||||
test ! -e "$hg131_results" || exit 2
|
|
||||||
mkdir -p "$hg131_results" || exit
|
|
||||||
hg131_test_status=0
|
|
||||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests \
|
|
||||||
-c Release --logger trx --results-directory "$hg131_results" || hg131_test_status=$?
|
|
||||||
hg131_guard_status=0
|
|
||||||
MIN_EXECUTED=1 bash scripts/ci/assert-not-all-skipped.sh "$hg131_results"/*.trx \
|
|
||||||
|| hg131_guard_status=$?
|
|
||||||
if [ "$hg131_test_status" -ne 0 ]; then exit "$hg131_test_status"; fi
|
|
||||||
exit "$hg131_guard_status"
|
|
||||||
```
|
|
||||||
|
|
||||||
Published-package verification must use the real 1.0.0 packages, preserve all test
|
|
||||||
and guard exits, run the full owning project, and complete the consumer solution
|
|
||||||
build/review before issue 131 is closed. The patch is transport-only and can be
|
|
||||||
applied independently of the OPC UA SDK 2 rollout.
|
|
||||||
|
|
||||||
For isolated validation, use a fresh output directory OUTSIDE both repositories
|
|
||||||
so the archived HG projects do not inherit the consumer's central package policy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/validation/historiangw-131/validate.py \
|
|
||||||
--consumer-root "$PWD" \
|
|
||||||
--hg-root /Users/dohertj2/Desktop/HistorianGateway \
|
|
||||||
--output /tmp/historiangw-131-fresh-validation
|
|
||||||
```
|
|
||||||
|
|
||||||
The script checks both live feed indexes. If both actual stable 1.0.0 packages are
|
|
||||||
available, it validates them in an archived consumer copy. Otherwise it archives
|
|
||||||
exact HG commit e432191 and temporarily replaces ONLY the archived Gateway csproj's
|
|
||||||
two package references with source project references. This equivalent source
|
|
||||||
validation does not create or publish any fake package/version. Restore uses an
|
|
||||||
independent copy-on-write/copy cache, isolated HTTP cache and CLI home. Neither
|
|
||||||
user checkout is built or edited. Full owning-suite TRX, required TLS failure
|
|
||||||
classification, command exits and execution guard are retained under the task
|
|
||||||
output. Published mode additionally checks package assets/provenance, absence of
|
|
||||||
source overrides, and exact package-to-test-bin DLL hashes. Never treat source
|
|
||||||
validation as stable published-package verification.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# HG issue 131 — actual published 1.0.0 verification
|
|
||||||
|
|
||||||
Both real stable **Client 1.0.0** and **Contracts 1.0.0** were observed in the live
|
|
||||||
Gitea indexes at 2026-11-10 19:35:54 UTC and restored into a fresh isolated cache.
|
|
||||||
The future consumer patch was applied only to a separate SDK 2 consumer archive.
|
|
||||||
Application files in this working branch remain on 0.4.0, unchanged.
|
|
||||||
|
|
||||||
- Full Gateway owning project: **529 total, 523 passed, 0 failed, 6 existing live skips**.
|
|
||||||
- Restore, test and repository execution-count guard exits: **0 / 0 / 0**.
|
|
||||||
- All **3 real TLS/h2c wire cases passed** against the published packages.
|
|
||||||
- Strict default rejected with AuthenticationException **UntrustedRoot**, before RPC.
|
|
||||||
- Explicit opt-out and plaintext/stale-TLS-settings cases each completed one RPC.
|
|
||||||
- Both assets entries are **type=package, version=1.0.0**, from the actual Gitea
|
|
||||||
source. No source ProjectReference override: the archived Gateway csproj is
|
|
||||||
byte-identical to the normal consumer csproj.
|
|
||||||
- The only package folder is the task-isolated cache. Both copied test-bin DLL
|
|
||||||
hashes equal the package DLL hashes. No package was packed or published here.
|
|
||||||
|
|
||||||
Actual package RepositoryCommit for BOTH packages:
|
|
||||||
`ea9f21c850fb618767e12690924f9966f1e433ac`.
|
|
||||||
Consumer archive base: `4ea379fa92e571492ff9f49889a5eb91e3ad1e1a`.
|
|
||||||
Applied patch SHA-256: `9452ee82b2bace7828a384b9f6048e9c931e8b4ec2078e0ee1baa175d0652fac`.
|
|
||||||
|
|
||||||
| Package | NuGet package SHA-256 | Consumed DLL SHA-256 |
|
|
||||||
|---|---|---|
|
|
||||||
| ZB.MOM.WW.HistorianGateway.Client 1.0.0 | `0a3f2836727671c07c0c393e705cc9737d2ed44258213a51fb4acf0d0ab7129e` | `b89a02e0d228ec549c4dfb755694f7f67b573a6077f06ff2781165e884fc5e12` |
|
|
||||||
| ZB.MOM.WW.HistorianGateway.Contracts 1.0.0 | `83ffd12bcbbf919c973ba522560e10aa93a4cb8c686a3232030a2cfa5f00995e` | `e60d3d4650be5d251982f7847404daf1b666f61d56e2b0ccb844af8677e97743` |
|
|
||||||
|
|
||||||
Proof metadata/source/hash/readback checks:
|
|
||||||
`/tmp/historiangw-131-published-20261110T193638Z/published-package-proof.json`.
|
|
||||||
Full run metadata (including typed TLS error and package readback):
|
|
||||||
`/tmp/historiangw-131-published-20261110T193638Z/metadata.json`.
|
|
||||||
Fresh actual-package TRX: `/tmp/historiangw-131-published-20261110T193638Z/test-results`.
|
|
||||||
Restore/test/guard logs: `/tmp/historiangw-131-published-20261110T193638Z`.
|
|
||||||
|
|
||||||
This completes the isolated SDK 2 owning-project proof. The coordinator separately
|
|
||||||
owns the consumer-master full solution build, Astra review, landing/push and issue
|
|
||||||
131 comment/closure. No issue action, package publication, or HG user-checkout
|
|
||||||
mutation was performed by this validation lane. The live AVEVA fixture skips do
|
|
||||||
not substitute for the three executed local wire tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Earlier archived-source validation
|
|
||||||
|
|
||||||
Future consumer patch validated against exact archived HG source commit
|
|
||||||
`e4321915ebc5e0704a68ecad21b85c1cc1c80d6b`. Consumer archive base:
|
|
||||||
`18c22cf7eb042126226e540d9b979907328055c3`. Patch SHA-256:
|
|
||||||
`9452ee82b2bace7828a384b9f6048e9c931e8b4ec2078e0ee1baa175d0652fac`.
|
|
||||||
|
|
||||||
## Scope and isolation
|
|
||||||
|
|
||||||
Mode: **unreleased source project references**, not published-package verification.
|
|
||||||
At that earlier source-validation time, both feed indexes still listed only
|
|
||||||
0.4.0, 0.3.0, 0.2.0, 0.1.0. The published-package run below supersedes that pending
|
|
||||||
release-availability status.
|
|
||||||
|
|
||||||
The user's HG checkout was only read by git archive. Only the disposable archived
|
|
||||||
consumer Gateway csproj had its two package references replaced by project
|
|
||||||
references into the exact archived HG source. No package was packed or published;
|
|
||||||
no fake 1.0.0 was inserted into any feed/cache. Restore used an independent copied
|
|
||||||
NuGet cache, isolated HTTP cache and CLI home. The normal consumer files remain
|
|
||||||
on published 0.4.0.
|
|
||||||
|
|
||||||
## Actual execution
|
|
||||||
|
|
||||||
- Full Gateway owning project: **529 total, 523 passed, 0 failed, 6 existing live skips**.
|
|
||||||
- Test exit **0**; repository nonzero-execution guard exit **0**.
|
|
||||||
- All three new local wire tests passed; no new skip or expected application-result change.
|
|
||||||
- Strict default: base HistorianGatewayException -> RpcException Status Internal ->
|
|
||||||
HttpRequestException -> AuthenticationException, certificate-chain **UntrustedRoot**.
|
|
||||||
The test explicitly requires the typed TLS authentication failure; RPC calls = **0**.
|
|
||||||
- Explicit TLS opt-out: actual metadata RPC succeeds; calls = **1**.
|
|
||||||
- Plaintext with stale opt-out and nonexistent CA path: actual metadata RPC succeeds;
|
|
||||||
calls = **1**.
|
|
||||||
|
|
||||||
The first source run compiled and executed but failed one new assertion because
|
|
||||||
it predicted the narrower HistorianGatewayUnavailableException type. The actual
|
|
||||||
TLS exception is the public base HistorianGatewayException; the corrected assertion
|
|
||||||
retains the required AuthenticationException graph and zero-RPC check. The fresh
|
|
||||||
second unfiltered run above passed. The six skipped cases are the existing AVEVA
|
|
||||||
live fixture tests; they are not a substitute for TLS wire coverage.
|
|
||||||
|
|
||||||
Completed UTC: `2026-11-10T19:33:17.272468+00:00`.
|
|
||||||
Actual metadata, typed TLS exception output and binary hashes:
|
|
||||||
`/tmp/historiangw-131-validation-20261110T193106Z/metadata-final.json`.
|
|
||||||
Fresh second-run TRX: `/tmp/historiangw-131-validation-20261110T193106Z/test-results2`.
|
|
||||||
Test log: `/tmp/historiangw-131-validation-20261110T193106Z/test2.log`.
|
|
||||||
Initial failed run and restore logs are retained in the same task directory.
|
|
||||||
|
|
||||||
Archived-source Client DLL SHA-256:
|
|
||||||
`87e995a43c63debdd8f648a8feb8d19d9110b45eb7b2b6906159077d90109862`.
|
|
||||||
Archived-source Contracts DLL SHA-256:
|
|
||||||
`ebaaa43ffc485288054eb864a59ec59264d8d9c4ea6871319fd7995192370751`.
|
|
||||||
|
|
||||||
That earlier run consumed source only. Actual stable-package proof is recorded
|
|
||||||
above. Consumer-master solution build/review/landing and issue handling are owned
|
|
||||||
by the coordinator; no issue comment/close was performed here.
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
diff --git a/Directory.Packages.props b/Directory.Packages.props
|
|
||||||
--- a/Directory.Packages.props
|
|
||||||
+++ b/Directory.Packages.props
|
|
||||||
@@ -242,7 +242,7 @@
|
|
||||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Replicator.AkkaDotNet" Version="0.6.2" />
|
|
||||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
|
||||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.5.0" />
|
|
||||||
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="0.4.0" />
|
|
||||||
- <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="0.4.0" />
|
|
||||||
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Client" Version="1.0.0" />
|
|
||||||
+ <PackageVersion Include="ZB.MOM.WW.HistorianGateway.Contracts" Version="1.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
\ No newline at end of file
|
|
||||||
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
|
|
||||||
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
|
|
||||||
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/HistorianGatewayClientAdapter.cs
|
|
||||||
@@ -57,23 +57,15 @@
|
|
||||||
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
- // TLS-only options must stay at their defaults on a plaintext h2c connection: the client rejects
|
|
||||||
- // each of them outright when UseTls=false ("<name> is a TLS-only option and requires
|
|
||||||
- // UseTls=true"). Forwarding them unconditionally made h2c UNREACHABLE — AllowUntrustedServer-
|
|
||||||
- // Certificate defaults to false, so RequireCertificateValidation was always sent as true and
|
|
||||||
- // every http:// deployment crashed at startup, even though the scheme is documented as the
|
|
||||||
- // supported way to select h2c. There is no certificate to have a posture about here.
|
|
||||||
+ // Certificate settings apply only to TLS. The client rejects an explicit opt-out or a
|
|
||||||
+ // CA path on h2c, where there is no certificate to validate.
|
|
||||||
var clientOptions = new HistorianGatewayClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = endpointUri,
|
|
||||||
ApiKey = options.ApiKey,
|
|
||||||
UseTls = options.UseTls,
|
|
||||||
CaCertificatePath = options.UseTls ? options.CaCertificatePath : null,
|
|
||||||
- // INVERTED mapping (TLS only): ServerHistorianOptions.AllowUntrustedServerCertificate
|
|
||||||
- // (opt-in to accept a self-signed cert) is the negation of the client's
|
|
||||||
- // RequireCertificateValidation. Allowing an untrusted cert == not requiring validation; a
|
|
||||||
- // pinned CaCertificatePath always verifies.
|
|
||||||
- RequireCertificateValidation = options.UseTls && !options.AllowUntrustedServerCertificate,
|
|
||||||
+ AllowUntrustedServerCertificate = options.UseTls && options.AllowUntrustedServerCertificate,
|
|
||||||
DefaultCallTimeout = options.CallTimeout,
|
|
||||||
LoggerFactory = loggerFactory,
|
|
||||||
};
|
|
||||||
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
|
|
||||||
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
|
|
||||||
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayClientAdapterTests.cs
|
|
||||||
@@ -27,23 +27,6 @@
|
|
||||||
/// A plaintext <c>h2c</c> gateway (an <c>http://</c> endpoint with <c>UseTls=false</c>) must
|
|
||||||
/// construct, using nothing but documented defaults.
|
|
||||||
/// </summary>
|
|
||||||
- /// <remarks>
|
|
||||||
- /// <para>
|
|
||||||
- /// Found by the LocalDb Phase 2 live gate, on the third consecutive crash-loop of the rig.
|
|
||||||
- /// <c>docs/Historian.md</c> and CLAUDE.md both document <c>http://</c> as a supported
|
|
||||||
- /// transport ("scheme selects transport: https = TLS, http = h2c"), but it was in fact
|
|
||||||
- /// <b>unreachable</b>: this factory forwarded the TLS-only options unconditionally, and
|
|
||||||
- /// <see cref="ServerHistorianOptions.AllowUntrustedServerCertificate"/> defaults to
|
|
||||||
- /// <see langword="false"/>, so it always sent <c>RequireCertificateValidation=true</c> —
|
|
||||||
- /// which the client rejects outright when <c>UseTls=false</c>
|
|
||||||
- /// ("RequireCertificateValidation is a TLS-only option and requires UseTls=true").
|
|
||||||
- /// </para>
|
|
||||||
- /// <para>
|
|
||||||
- /// So <b>every</b> h2c deployment crashed at startup, and the only way to avoid it was to
|
|
||||||
- /// set <c>AllowUntrustedServerCertificate=true</c> — asserting a certificate posture for a
|
|
||||||
- /// connection that has no certificate at all.
|
|
||||||
- /// </para>
|
|
||||||
- /// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public void Adapter_constructs_for_plaintext_h2c_endpoint()
|
|
||||||
{
|
|
||||||
@@ -71,6 +54,7 @@
|
|
||||||
ApiKey = "histgw_x_y",
|
|
||||||
UseTls = false,
|
|
||||||
CaCertificatePath = "/etc/ssl/certs/leftover-from-a-tls-config.pem",
|
|
||||||
+ AllowUntrustedServerCertificate = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
using var adapter = HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance);
|
|
||||||
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
|
|
||||||
new file mode 100644
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
|
|
||||||
@@ -0,0 +1,142 @@
|
|
||||||
+using System.Net;
|
|
||||||
+using System.Security.Authentication;
|
|
||||||
+using System.Security.Cryptography;
|
|
||||||
+using System.Security.Cryptography.X509Certificates;
|
|
||||||
+using Grpc.Core;
|
|
||||||
+using Microsoft.AspNetCore.Builder;
|
|
||||||
+using Microsoft.AspNetCore.Hosting;
|
|
||||||
+using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
||||||
+using Microsoft.Extensions.DependencyInjection;
|
|
||||||
+using Microsoft.Extensions.Logging;
|
|
||||||
+using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
+using Shouldly;
|
|
||||||
+using Xunit;
|
|
||||||
+using ZB.MOM.WW.HistorianGateway.Client;
|
|
||||||
+using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
||||||
+using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
||||||
+
|
|
||||||
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
||||||
+
|
|
||||||
+/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
|
|
||||||
+public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output)
|
|
||||||
+{
|
|
||||||
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
||||||
+
|
|
||||||
+ [Fact]
|
|
||||||
+ public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc()
|
|
||||||
+ {
|
|
||||||
+ await using var server = await Server.StartAsync(tls: true);
|
|
||||||
+ using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance);
|
|
||||||
+
|
|
||||||
+ var error = await Should.ThrowAsync<HistorianGatewayException>(
|
|
||||||
+ () => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct));
|
|
||||||
+ output.WriteLine($"TLS rejection: {error}");
|
|
||||||
+ ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure");
|
|
||||||
+ server.MetadataCalls.ShouldBe(0);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ [Fact]
|
|
||||||
+ public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape()
|
|
||||||
+ {
|
|
||||||
+ await using var server = await Server.StartAsync(tls: true);
|
|
||||||
+ var options = server.Options(allowUntrusted: true);
|
|
||||||
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
|
|
||||||
+
|
|
||||||
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
|
|
||||||
+ server.MetadataCalls.ShouldBe(1);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ [Fact]
|
|
||||||
+ public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc()
|
|
||||||
+ {
|
|
||||||
+ await using var server = await Server.StartAsync(tls: false);
|
|
||||||
+ var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem");
|
|
||||||
+ using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
|
|
||||||
+
|
|
||||||
+ (await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
|
|
||||||
+ server.MetadataCalls.ShouldBe(1);
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ private static bool ContainsTlsAuthenticationFailure(Exception error)
|
|
||||||
+ {
|
|
||||||
+ var pending = new Stack<Exception>();
|
|
||||||
+ var seen = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
|
|
||||||
+ pending.Push(error);
|
|
||||||
+ while (pending.TryPop(out var current))
|
|
||||||
+ {
|
|
||||||
+ if (!seen.Add(current)) continue;
|
|
||||||
+ if (current is AuthenticationException) return true;
|
|
||||||
+ if (current.InnerException is { } inner) pending.Push(inner);
|
|
||||||
+ if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug);
|
|
||||||
+ }
|
|
||||||
+ return false;
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ private sealed class TagsService : HistorianTags.HistorianTagsBase
|
|
||||||
+ {
|
|
||||||
+ public int Calls;
|
|
||||||
+ public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
|
|
||||||
+ {
|
|
||||||
+ Interlocked.Increment(ref Calls);
|
|
||||||
+ return Task.FromResult(new GetTagMetadataReply
|
|
||||||
+ { Metadata = new HistorianTagMetadata { Name = request.Tag } });
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ private sealed class Server(WebApplication app, TagsService service, string endpoint,
|
|
||||||
+ RSA? key, X509Certificate2? certificate) : IAsyncDisposable
|
|
||||||
+ {
|
|
||||||
+ public int MetadataCalls => Volatile.Read(ref service.Calls);
|
|
||||||
+ public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new()
|
|
||||||
+ {
|
|
||||||
+ Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null,
|
|
||||||
+ CallTimeout = TimeSpan.FromSeconds(2),
|
|
||||||
+ AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath,
|
|
||||||
+ };
|
|
||||||
+
|
|
||||||
+ public static async Task<Server> StartAsync(bool tls)
|
|
||||||
+ {
|
|
||||||
+ RSA? key = null;
|
|
||||||
+ X509Certificate2? certificate = null;
|
|
||||||
+ if (tls)
|
|
||||||
+ {
|
|
||||||
+ key = RSA.Create(2048);
|
|
||||||
+ var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
||||||
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
|
||||||
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(
|
|
||||||
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
|
|
||||||
+ var names = new SubjectAlternativeNameBuilder();
|
|
||||||
+ names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback);
|
|
||||||
+ request.CertificateExtensions.Add(names.Build());
|
|
||||||
+ certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
|
|
||||||
+ }
|
|
||||||
+ var service = new TagsService();
|
|
||||||
+ var builder = WebApplication.CreateBuilder();
|
|
||||||
+ builder.Logging.ClearProviders();
|
|
||||||
+ builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen =>
|
|
||||||
+ {
|
|
||||||
+ listen.Protocols = HttpProtocols.Http2;
|
|
||||||
+ if (certificate is not null) listen.UseHttps(certificate);
|
|
||||||
+ }));
|
|
||||||
+ builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
|
|
||||||
+ var app = builder.Build();
|
|
||||||
+ app.MapGrpcService<TagsService>();
|
|
||||||
+ try
|
|
||||||
+ {
|
|
||||||
+ await app.StartAsync(Ct);
|
|
||||||
+ return new Server(app, service, app.Urls.Single(), key, certificate);
|
|
||||||
+ }
|
|
||||||
+ catch
|
|
||||||
+ {
|
|
||||||
+ await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose();
|
|
||||||
+ throw;
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ public async ValueTask DisposeAsync()
|
|
||||||
+ {
|
|
||||||
+ try { await app.StopAsync(Ct); }
|
|
||||||
+ finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); }
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Validate the future patch in archives, using published 1.0.0 or exact HG source project references."""
|
|
||||||
import argparse, datetime, hashlib, io, json, os, shutil, subprocess, tarfile, urllib.request
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def verify_published_packages(root, consumer, cache, project):
|
|
||||||
relative = Path('src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj')
|
|
||||||
assert (consumer/relative).read_bytes() == (root/relative).read_bytes(), 'Unexpected source-project override'
|
|
||||||
assets = json.loads((consumer/project.parent/'obj/project.assets.json').read_text())
|
|
||||||
assert all(Path(p).resolve() == cache.resolve() for p in assets['packageFolders'])
|
|
||||||
proof = {'no_source_project_reference_override': True, 'packages': {}}
|
|
||||||
for name in ['Client', 'Contracts']:
|
|
||||||
ident = 'ZB.MOM.WW.HistorianGateway.' + name
|
|
||||||
library = assets['libraries'][ident + '/1.0.0']
|
|
||||||
assert library['type'] == 'package'
|
|
||||||
package = cache/ident.lower()/'1.0.0'
|
|
||||||
provenance = json.loads((package/'.nupkg.metadata').read_text())
|
|
||||||
assert provenance['source'].startswith('https://gitea.dohertylan.com/api/packages/dohertj2/nuget')
|
|
||||||
dll = package/'lib/net10.0'/(ident + '.dll')
|
|
||||||
copied = consumer/project.parent/'bin/Release/net10.0'/(ident + '.dll')
|
|
||||||
digest = hashlib.sha256(dll.read_bytes()).hexdigest()
|
|
||||||
assert digest == hashlib.sha256(copied.read_bytes()).hexdigest()
|
|
||||||
nuspec = ET.parse(package/(ident.lower() + '.nuspec'))
|
|
||||||
assert nuspec.findtext('.//{*}version') == '1.0.0'
|
|
||||||
repository = nuspec.find('.//{*}repository')
|
|
||||||
proof['packages'][ident] = {'version': '1.0.0', 'asset_type': 'package',
|
|
||||||
'source': provenance['source'], 'dll_sha256': digest,
|
|
||||||
'nupkg_sha256': hashlib.sha256((package/(ident.lower() + '.1.0.0.nupkg')).read_bytes()).hexdigest(),
|
|
||||||
'repository': repository.attrib if repository is not None else None,
|
|
||||||
'test_bin_dll_matches_package': True}
|
|
||||||
return proof
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument('--consumer-root', type=Path, required=True)
|
|
||||||
parser.add_argument('--hg-root', type=Path, required=True)
|
|
||||||
parser.add_argument('--hg-commit', default='e4321915ebc5e0704a68ecad21b85c1cc1c80d6b')
|
|
||||||
parser.add_argument('--output', type=Path, required=True)
|
|
||||||
a = parser.parse_args(); root = a.consumer_root.resolve(); out = a.output.resolve()
|
|
||||||
if out.is_relative_to(root) or out.is_relative_to(a.hg_root.resolve()):
|
|
||||||
raise ValueError('Use an output directory outside both checkouts so archived HG does not inherit consumer central-package settings.')
|
|
||||||
out.mkdir(parents=True, exist_ok=False)
|
|
||||||
patch = Path(__file__).with_name('consumer-1.0.0.patch').resolve()
|
|
||||||
project = Path('tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj')
|
|
||||||
sha = subprocess.check_output(['git','-C',str(root),'rev-parse','HEAD'],text=True).strip()
|
|
||||||
hg_sha = subprocess.check_output(['git','-C',str(a.hg_root),'rev-parse',a.hg_commit],text=True).strip()
|
|
||||||
def archive(repo, rev, target):
|
|
||||||
target.mkdir()
|
|
||||||
data = subprocess.check_output(['git','-C',str(repo),'archive',rev])
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(data)) as tar: tar.extractall(target, filter='data')
|
|
||||||
|
|
||||||
versions = {}
|
|
||||||
for name in ['client','contracts']:
|
|
||||||
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/package/zb.mom.ww.historiangateway.' + name + '/index.json'
|
|
||||||
versions[name] = json.load(urllib.request.urlopen(url,timeout=20))['versions']
|
|
||||||
published = all('1.0.0' in v for v in versions.values())
|
|
||||||
metadata = {'utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'consumer_commit':sha,'hg_snapshot':hg_sha,
|
|
||||||
'mode':'published-1.0.0' if published else 'unreleased-source-project-reference',
|
|
||||||
'feed_versions':versions,'patch_sha256':hashlib.sha256(patch.read_bytes()).hexdigest(),
|
|
||||||
'published_package_verification':published,'normal_package_cache_modified':False}
|
|
||||||
(out/'metadata.json').write_text(json.dumps(metadata,indent=2))
|
|
||||||
consumer = out/'consumer'; archive(root,sha,consumer)
|
|
||||||
subprocess.run(['git','init','-q',str(consumer)],check=True)
|
|
||||||
subprocess.run(['git','-C',str(consumer),'apply',str(patch)],check=True)
|
|
||||||
if not published:
|
|
||||||
hg = out/'historiangw-source'; archive(a.hg_root,hg_sha,hg)
|
|
||||||
adapter = consumer/'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.csproj'
|
|
||||||
tree = ET.parse(adapter)
|
|
||||||
removed = 0
|
|
||||||
for group in tree.getroot().findall('ItemGroup'):
|
|
||||||
for item in list(group):
|
|
||||||
if item.tag=='PackageReference' and item.get('Include') in ['ZB.MOM.WW.HistorianGateway.Client','ZB.MOM.WW.HistorianGateway.Contracts']:
|
|
||||||
group.remove(item);removed += 1
|
|
||||||
assert removed==2
|
|
||||||
group = ET.SubElement(tree.getroot(),'ItemGroup')
|
|
||||||
for relative in ['clients/dotnet/ZB.MOM.WW.HistorianGateway.Client/ZB.MOM.WW.HistorianGateway.Client.csproj',
|
|
||||||
'src/ZB.MOM.WW.HistorianGateway.Contracts/ZB.MOM.WW.HistorianGateway.Contracts.csproj']:
|
|
||||||
ET.SubElement(group,'ProjectReference',Include=str(hg/relative))
|
|
||||||
tree.write(adapter,encoding='unicode')
|
|
||||||
metadata['temporary_override']='Only archived Gateway csproj: two package references replaced by exact archived HG project references; no package packed or published.'
|
|
||||||
# Copy cached dependencies independently, preferably APFS copy-on-write, into the isolated package root.
|
|
||||||
cache = out/'packages'; cache.mkdir()
|
|
||||||
assets = json.loads((root/project.parent/'obj/project.assets.json').read_text())
|
|
||||||
normal = Path(next(iter(assets['packageFolders'])))
|
|
||||||
for library in assets['libraries'].values():
|
|
||||||
if library.get('type')!='package':continue
|
|
||||||
relative = library['path']; source=normal/relative; target=cache/relative
|
|
||||||
target.parent.mkdir(parents=True,exist_ok=True)
|
|
||||||
if target.exists():continue
|
|
||||||
r=subprocess.run(['cp','-cR',str(source),str(target)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
|
||||||
if r.returncode: shutil.copytree(source,target)
|
|
||||||
config = ET.parse(root/'NuGet.config')
|
|
||||||
for source in config.getroot().find('packageSources'):
|
|
||||||
if source.get('key')=='local-mxgw':
|
|
||||||
local = root/'nuget-packages'
|
|
||||||
if not local.exists():local=out/'empty-local';local.mkdir()
|
|
||||||
source.set('value',str(local))
|
|
||||||
config.write(out/'NuGet.config',encoding='unicode')
|
|
||||||
env=dict(os.environ,NUGET_PACKAGES=str(cache),NUGET_HTTP_CACHE_PATH=str(out/'http-cache'),DOTNET_CLI_HOME=str(out/'dotnet-home'),
|
|
||||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE='1',DOTNET_GENERATE_ASPNET_CERTIFICATE='false',DOTNET_CLI_TELEMETRY_OPTOUT='1')
|
|
||||||
commands=[]
|
|
||||||
def run(args,name):
|
|
||||||
with (out/name).open('w') as log:r=subprocess.run(args,cwd=consumer,env=env,stdout=log,stderr=subprocess.STDOUT)
|
|
||||||
commands.append({'argv':args,'exit':r.returncode,'log':name})
|
|
||||||
(out/'commands.json').write_text(json.dumps(commands,indent=2));print(name,'exit',r.returncode,flush=True)
|
|
||||||
return r.returncode
|
|
||||||
restore = run(['dotnet','restore',str(project),'--configfile',str(out/'NuGet.config'),'--packages',str(cache)],'restore.log')
|
|
||||||
metadata['restore_exit']=restore
|
|
||||||
if restore:
|
|
||||||
(out/'metadata.json').write_text(json.dumps(metadata,indent=2));raise SystemExit(restore)
|
|
||||||
results=out/'test-results'
|
|
||||||
test = run(['dotnet','test',str(project),'-c','Release','--no-restore','--logger','trx','--results-directory',str(results)],'test.log')
|
|
||||||
guard_env=env.copy();guard_env['MIN_EXECUTED']='1'
|
|
||||||
with (out/'guard.log').open('w') as log:
|
|
||||||
guard=subprocess.run(['bash',str(consumer/'scripts/ci/assert-not-all-skipped.sh'),*[str(p) for p in results.glob('*.trx')]],env=guard_env,stdout=log,stderr=subprocess.STDOUT)
|
|
||||||
metadata.update({'test_exit':test,'guard_exit':guard.returncode})
|
|
||||||
ns={'t':'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'}
|
|
||||||
metadata['results']=[]
|
|
||||||
for trx in results.glob('*.trx'):
|
|
||||||
xml=ET.parse(trx);metadata['results'].append(xml.find('.//t:Counters',ns).attrib)
|
|
||||||
metadata['tls_cases']=[{'name':r.get('testName'),'outcome':r.get('outcome'),'output':'\n'.join(p.text or '' for p in r.findall('.//t:StdOut',ns)),
|
|
||||||
'error':r.findtext('.//t:Message','',ns)} for r in xml.findall('.//t:UnitTestResult',ns) if 'HistorianGatewayTlsPolicyWireTests' in r.get('testName','')]
|
|
||||||
if published:
|
|
||||||
metadata['package_proof'] = verify_published_packages(root, consumer, cache, project)
|
|
||||||
(out/'metadata.json').write_text(json.dumps(metadata,indent=2));print(json.dumps(metadata,indent=2),flush=True)
|
|
||||||
raise SystemExit(test if test else guard.returncode)
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
|
||||||
|
|
||||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(45));
|
|
||||||
var ct = deadline.Token;
|
|
||||||
var mode = args[0];
|
|
||||||
var root = Path.GetFullPath(args[1]);
|
|
||||||
if (mode == "seed")
|
|
||||||
{
|
|
||||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
||||||
{ ["LocalDb:Path"] = Path.Combine(root, "localdb.db") }).Build();
|
|
||||||
await using var provider = new ServiceCollection().AddLogging().AddOtOpcUaLocalDb(config).BuildServiceProvider();
|
|
||||||
var presented = Environment.GetEnvironmentVariable("SMOKE_API_KEY") ?? throw new InvalidOperationException("Missing task credential.");
|
|
||||||
if (!ApiKeySecret.TryParse(presented, out var id, out var secret)) throw new InvalidOperationException("Invalid task credential format.");
|
|
||||||
var pepper = Environment.GetEnvironmentVariable("Security__ApiKeys__Pepper") ?? throw new InvalidOperationException("Missing task pepper.");
|
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
||||||
{
|
|
||||||
Clusters = new[] { new { ClusterId = "smoke", Name = "Smoke" } },
|
|
||||||
RawFolders = new[] { new { RawFolderId = "smoke", Name = "Smoke", ClusterId = "smoke" } },
|
|
||||||
ApiKeys = new[] { new { ApiKeyId = id, Name = "publish-smoke", Principal = "publish-smoke",
|
|
||||||
Enabled = true, SecretHash = ApiKeySecret.ComputeHash(secret, pepper), CreatedUtc = DateTime.UtcNow } },
|
|
||||||
NodeAcls = new[] { new { NodeAclId = "smoke-read", ClusterId = "smoke", Principal = "publish-smoke",
|
|
||||||
ScopeKind = 0, ScopeId = (string?)null, PermissionFlags = 7 } },
|
|
||||||
});
|
|
||||||
var revision = Convert.ToHexStringLower(SHA256.HashData(artifact));
|
|
||||||
await provider.GetRequiredService<IDeploymentArtifactCache>().StoreAsync("smoke", ZB.MOM.WW.OtOpcUa.Commons.Types.DeploymentId.NewId().ToString(), revision, artifact, ct);
|
|
||||||
Console.WriteLine(JsonSerializer.Serialize(new { mode, result = "seeded-through-public-cache", revision }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
var clientName = mode == "prepare-cli" ? "OtOpcUaClient" : "PublishSmokeClient";
|
|
||||||
var security = new SecurityConfiguration
|
|
||||||
{
|
|
||||||
ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory,
|
|
||||||
StorePath = Path.Combine(root, "own"), SubjectName = "CN=" + clientName },
|
|
||||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "issuer") },
|
|
||||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "trusted") },
|
|
||||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(root, "rejected") },
|
|
||||||
AutoAcceptUntrustedCertificates = false,
|
|
||||||
};
|
|
||||||
var configuration = new ApplicationConfiguration(telemetry)
|
|
||||||
{
|
|
||||||
ApplicationName = clientName, ApplicationUri = "urn:localhost:" + clientName,
|
|
||||||
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
|
|
||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
||||||
};
|
|
||||||
await using var application = new ApplicationInstance(telemetry)
|
|
||||||
{ ApplicationName = configuration.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = configuration };
|
|
||||||
await configuration.ValidateAsync(ApplicationType.Client, ct);
|
|
||||||
await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
|
|
||||||
if (mode is "prepare" or "prepare-cli")
|
|
||||||
{
|
|
||||||
Console.WriteLine(JsonSerializer.Serialize(new { mode, result = "prepared", autoAccept = security.AutoAcceptUntrustedCertificates }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var endpoint = args[2];
|
|
||||||
var expected = args[3];
|
|
||||||
var credential = Environment.GetEnvironmentVariable("SMOKE_API_KEY") ?? throw new InvalidOperationException("Missing task credential.");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var selected = await CoreClientUtils.SelectEndpointAsync(configuration, endpoint, true, telemetry, ct)
|
|
||||||
?? throw new InvalidOperationException("No secure endpoint advertised.");
|
|
||||||
if (selected.SecurityMode != MessageSecurityMode.SignAndEncrypt || selected.SecurityPolicyUri != SecurityPolicies.Basic256Sha256)
|
|
||||||
throw new InvalidOperationException("Unexpected negotiated security policy.");
|
|
||||||
using var session = await new DefaultSessionFactory(telemetry).CreateAsync(configuration,
|
|
||||||
new ConfiguredEndpoint(null, selected, EndpointConfiguration.Create(configuration)), false, true,
|
|
||||||
"PublishSmoke", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
|
|
||||||
if (expected != "allow") throw new InvalidOperationException("A denied test credential or peer was admitted.");
|
|
||||||
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Both,
|
|
||||||
[new ReadValueId { NodeId = VariableIds.Server_ServerStatus_State, AttributeId = Attributes.Value }], ct);
|
|
||||||
if (response.Results.Count != 1 || StatusCode.IsBad(response.Results[0].StatusCode)) throw new InvalidOperationException("Server status read failed.");
|
|
||||||
Console.WriteLine(JsonSerializer.Serialize(new { mode, expected, result = "allowed", policy = selected.SecurityPolicyUri,
|
|
||||||
securityMode = selected.SecurityMode.ToString(), status = response.Results[0].StatusCode.ToString(), autoAccept = false }));
|
|
||||||
await session.CloseAsync(ct);
|
|
||||||
}
|
|
||||||
catch (ServiceResultException error) when (expected != "allow")
|
|
||||||
{
|
|
||||||
var accepted = expected == "key-deny"
|
|
||||||
? new[] { StatusCodes.BadUserAccessDenied, StatusCodes.BadIdentityTokenRejected, StatusCodes.BadIdentityTokenInvalid }
|
|
||||||
: new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed };
|
|
||||||
if (!accepted.Contains(error.StatusCode)) throw;
|
|
||||||
Console.WriteLine(JsonSerializer.Serialize(new { mode, expected, result = "denied", status = error.StatusCode.ToString(), autoAccept = false }));
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="../../../src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Actual Host publish smoke
|
|
||||||
|
|
||||||
From an isolated candidate checkout, publish the actual Host and CLI for Linux ARM64
|
|
||||||
and Windows x64 using the install recipe's self-contained setting. Do not use a
|
|
||||||
checkout that another process is building, and choose a new artifact directory.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
task_output=/absolute/fresh/publish-smoke
|
|
||||||
test ! -e "$task_output" || exit 2
|
|
||||||
mkdir -p "$task_output"
|
|
||||||
for rid in linux-arm64 win-x64; do
|
|
||||||
dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj \
|
|
||||||
-c Release -r "$rid" --self-contained true \
|
|
||||||
-p:PublishSingleFile=false -p:PublishTrimmed=false -m:1 \
|
|
||||||
-o "$task_output/$rid/host" || exit
|
|
||||||
dotnet publish src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj \
|
|
||||||
-c Release -r "$rid" --self-contained true \
|
|
||||||
-p:PublishSingleFile=false -p:PublishTrimmed=false -m:1 \
|
|
||||||
-o "$task_output/$rid/cli" || exit
|
|
||||||
done
|
|
||||||
dotnet publish scripts/validation/opcua-publish-smoke/PublishProbe.csproj \
|
|
||||||
-c Release -m:1 -o "$task_output/probe" || exit
|
|
||||||
python3 scripts/validation/opcua-publish-smoke/runtime.py --artifacts "$task_output"
|
|
||||||
```
|
|
||||||
|
|
||||||
The cached `mcr.microsoft.com/dotnet/sdk:10.0.103-noble` image must be ARM64 for this
|
|
||||||
Linux artifact. The SDK image supplies the small probe's framework; the actual
|
|
||||||
Host and CLI are launched through their published self-contained apphosts. There
|
|
||||||
is no Native AOT, trimming, or single-file transformation.
|
|
||||||
|
|
||||||
The runtime script creates a fresh private state directory, ephemeral keys and
|
|
||||||
loopback ports. A temporary preparation container seeds the LocalDb cache through
|
|
||||||
public production registration/cache APIs. The artifact contains an empty driver
|
|
||||||
configuration, one raw folder, and a real HMAC API key with read/browse/subscribe
|
|
||||||
grants. No production authentication seam is replaced. The actual Host uses
|
|
||||||
`Cluster:Roles=driver`, `ConfigSource:Mode=FetchAndCache`, an unreachable loopback
|
|
||||||
central endpoint, and task-only LocalDb, secrets SQLite and GUID PKI paths. Its
|
|
||||||
single-node Akka seed is local. Every container has `--network none`; no fleet
|
|
||||||
endpoint, ConfigDb, existing container, volume, or PKI is used.
|
|
||||||
|
|
||||||
One task-owned container runs the actual Host as its entrypoint, stops cleanly,
|
|
||||||
then starts the same executable with the same state and PKI again. Each boot must
|
|
||||||
serve healthy readiness, liveness and active endpoints, admit a trusted
|
|
||||||
Basic256Sha256 SignAndEncrypt API-key client, reject a wrong key, and allow an
|
|
||||||
actual published CLI read of ServerStatus.State with Good quality. The first boot
|
|
||||||
also rejects an untrusted server and an untrusted client. The two certificate
|
|
||||||
thumbprints must match. Both stops must exit zero without an OOM kill. An explicit
|
|
||||||
execution guard requires two boots and all six strict SDK probe cases.
|
|
||||||
|
|
||||||
The probe retains its public SDK ApplicationInstance throughout its session and
|
|
||||||
disposes it afterward. Peer autoaccept is false. The CLI's existing configuration
|
|
||||||
sets autoaccept true and has no strict-validation flag, so its successful reads
|
|
||||||
are not counted as strict peer-validation coverage. Its certificate is prepared
|
|
||||||
with its normal application name and URI using public SDK APIs and trusted by the
|
|
||||||
server before startup. A closed-port CLI preflight is an expected nonzero setup
|
|
||||||
check, not a passing connection case.
|
|
||||||
|
|
||||||
`commands.json`, per-case logs, health JSON and `summary.json` retain actual exits,
|
|
||||||
results and certificate identity. Secrets are supplied through a mode-0600 task
|
|
||||||
environment file, never written into recorded arguments, and the file is removed
|
|
||||||
at the end. Raw process output and this run's appended rolling-file output are
|
|
||||||
checked for task secrets before redacted copies are saved. Only the captured,
|
|
||||||
task-labelled container is removed; private bind-mounted state remains local for
|
|
||||||
inspection and must not be redistributed with release artifacts.
|
|
||||||
|
|
||||||
Windows cross-publish and PE/native/dependency inspection can run on macOS.
|
|
||||||
Windows service installation and start/stop remain a separate operator gate on
|
|
||||||
an approved Windows environment. The Wonder-box remote-execution hold still
|
|
||||||
applies: no WinRM, psbridge, or replacement remote-execution channel. This smoke
|
|
||||||
does not complete backend, load, failover, or two-hour soak validation.
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Smoke actual self-contained Linux Host/CLI artifacts with task-only PKI and LocalDb."""
|
|
||||||
import argparse, base64, datetime, hashlib, json, os, re, secrets, shutil, subprocess, time, uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
p = argparse.ArgumentParser(description=__doc__)
|
|
||||||
p.add_argument('--artifacts', required=True, type=Path)
|
|
||||||
p.add_argument('--image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble')
|
|
||||||
a = p.parse_args(); out = a.artifacts.resolve()
|
|
||||||
run = out / ('runtime-' + uuid.uuid4().hex); run.mkdir(exist_ok=False)
|
|
||||||
state = run / 'state'; state.mkdir(); os.chmod(state, 0o700)
|
|
||||||
identifier = uuid.uuid4().hex[:12]
|
|
||||||
web, ua, akka = [20000 + secrets.randbelow(10000) for _ in range(3)]
|
|
||||||
while len({web, ua, akka}) != 3: web, ua, akka = [20000 + secrets.randbelow(10000) for _ in range(3)]
|
|
||||||
key = 'otua_' + secrets.token_hex(8) + '_' + secrets.token_urlsafe(32)
|
|
||||||
pepper, fetch = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
|
||||||
master = base64.b64encode(secrets.token_bytes(32)).decode()
|
|
||||||
env = {
|
|
||||||
'ASPNETCORE_URLS': f'http://127.0.0.1:{web}', 'ASPNETCORE_ENVIRONMENT': 'Production',
|
|
||||||
'Cluster__Roles__0': 'driver',
|
|
||||||
'Cluster__SystemName': 'otopcua', 'Cluster__Hostname': '127.0.0.1',
|
|
||||||
'Cluster__PublicHostname': '127.0.0.1', 'Cluster__Port': str(akka),
|
|
||||||
'Cluster__SeedNodes__0': f'akka.tcp://otopcua@127.0.0.1:{akka}',
|
|
||||||
'ConfigSource__Mode': 'FetchAndCache', 'ConfigSource__CentralFetchEndpoints__0': 'http://127.0.0.1:1',
|
|
||||||
'ConfigSource__ApiKey': fetch, 'ConfigSource__FetchTimeoutSeconds': '1',
|
|
||||||
'LocalDb__Path': '/state/localdb.db', 'Secrets__SqlitePath': '/state/secrets.db',
|
|
||||||
'ZB_SECRETS_MASTER_KEY': master, 'Security__ApiKeys__Pepper': pepper,
|
|
||||||
'Security__Ldap__Enabled': 'false', 'Security__Acl__Enabled': 'false',
|
|
||||||
'OpcUa__ApplicationName': 'PublishSmoke-' + identifier,
|
|
||||||
'OpcUa__ApplicationUri': 'urn:localhost:PublishSmoke:' + identifier,
|
|
||||||
'OpcUa__PublicHostname': 'localhost', 'OpcUa__OpcUaPort': str(ua),
|
|
||||||
'OpcUa__PkiStoreRoot': '/state/server-pki-' + identifier,
|
|
||||||
'OpcUa__AllowAnonymous': 'false', 'OpcUa__AutoAcceptUntrustedClientCertificates': 'false',
|
|
||||||
'OpcUa__EnabledSecurityProfiles__0': 'Basic256Sha256SignAndEncrypt',
|
|
||||||
'SMOKE_API_KEY': key, 'XDG_DATA_HOME': '/state/cli-data', 'HOME': '/state/home',
|
|
||||||
}
|
|
||||||
envfile = run / 'private.env'; envfile.write_text(''.join(f'{k}={v}\n' for k,v in env.items())); os.chmod(envfile, 0o600)
|
|
||||||
redactions = [key, key.split('_', 2)[2], pepper, fetch, master]
|
|
||||||
def clean(text):
|
|
||||||
for secret in redactions: text = text.replace(secret, '<ephemeral-secret>')
|
|
||||||
return text
|
|
||||||
records = []
|
|
||||||
raw_secret_findings = []
|
|
||||||
rolling_root = out / 'linux-arm64/host/logs'
|
|
||||||
rolling_offsets = {p: p.stat().st_size for p in rolling_root.glob('*') if p.is_file()}
|
|
||||||
def command(args, name, expected=0, timeout=90):
|
|
||||||
start = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
||||||
result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
|
||||||
raw_secret_findings.append({'log': name, 'occurrences': sum(result.stdout.count(secret) for secret in redactions)})
|
|
||||||
(run / (name + '.log')).write_text(clean(result.stdout))
|
|
||||||
records.append({'name': name, 'utc': start, 'argv': [clean(str(x)) for x in args], 'exit': result.returncode})
|
|
||||||
(run / 'commands.json').write_text(json.dumps(records, indent=2))
|
|
||||||
if expected is not None and result.returncode != expected:
|
|
||||||
raise RuntimeError(f'{name}: exit {result.returncode}; see {run / (name + ".log")}')
|
|
||||||
return result
|
|
||||||
mounts = ['--mount', f'type=bind,src={out},dst=/work', '--mount', f'type=bind,src={state},dst=/state']
|
|
||||||
base = ['docker','run','--rm','--network','none','--label','codex.task=opcua-publish-smoke','--env-file',str(envfile),*mounts,'-w','/work/linux-arm64/host',a.image]
|
|
||||||
probe = ['dotnet','/work/probe/PublishProbe.dll']
|
|
||||||
cli = ['/work/linux-arm64/cli/ZB.MOM.WW.OtOpcUa.Client.CLI']
|
|
||||||
server_pki = state / ('server-pki-' + identifier)
|
|
||||||
container = None; summary = {'run': str(run), 'image': a.image, 'ports': {'http':web,'ua':ua,'akka':akka}, 'boots':[], 'cases':[]}
|
|
||||||
def copy_public(source, target):
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
certs = list(source.glob('*.der'))
|
|
||||||
if not certs: raise RuntimeError(f'No public certificates at {source}')
|
|
||||||
for cert in certs: shutil.copy2(cert, target / cert.name)
|
|
||||||
def inspect(): return json.loads(subprocess.check_output(['docker','inspect',container], text=True))[0]
|
|
||||||
def save_logs(name):
|
|
||||||
r = subprocess.run(['docker','logs',container],text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
|
|
||||||
raw_secret_findings.append({'log': name, 'occurrences': sum(r.stdout.count(secret) for secret in redactions)})
|
|
||||||
(run / (name + '.log')).write_text(clean(r.stdout))
|
|
||||||
def exec_probe(name, pki, expected, credential=None):
|
|
||||||
args = ['docker','exec']
|
|
||||||
if credential: args += ['-e','SMOKE_API_KEY=' + credential]
|
|
||||||
args += [container,*probe,'probe',pki,f'opc.tcp://localhost:{ua}/OtOpcUa',expected]
|
|
||||||
r = command(args,name)
|
|
||||||
payload = json.loads(r.stdout.strip().splitlines()[-1]); summary['cases'].append({'name':name,**payload})
|
|
||||||
try:
|
|
||||||
command([*base,*probe,'seed','/state'],'seed')
|
|
||||||
command([*base,*probe,'prepare','/state/client-pki'],'prepare-trusted-client')
|
|
||||||
command([*base,*probe,'prepare','/state/untrusted-client-pki'],'prepare-untrusted-client')
|
|
||||||
command([*base,*cli,'--help'],'cli-help')
|
|
||||||
command([*base,*probe,'prepare-cli','/state/cli-data/OtOpcUaClient/pki'],'prepare-cli-certificate')
|
|
||||||
failed = command([*base,*cli,'read','--url','opc.tcp://localhost:1/OtOpcUa','--security','encrypt','--username','apikey','--password',key,'--node','i=2259'],'cli-prepare-on-closed-port',expected=None)
|
|
||||||
if failed.returncode == 0: raise RuntimeError('Closed-port CLI fixture preparation unexpectedly succeeded.')
|
|
||||||
copy_public(state/'client-pki/own/certs',server_pki/'trusted/certs')
|
|
||||||
copy_public(state/'cli-data/OtOpcUaClient/pki/own/certs',server_pki/'trusted/certs')
|
|
||||||
name = 'otopcua-sdk2-publish-' + identifier
|
|
||||||
result = command(['docker','create','--name',name,'--network','none','--label','codex.task=opcua-publish-smoke','--env-file',str(envfile),*mounts,'-w','/work/linux-arm64/host',a.image,'./OtOpcUa.Host'],'container-create')
|
|
||||||
container = result.stdout.strip()
|
|
||||||
summary['container'] = container
|
|
||||||
for boot in [1,2]:
|
|
||||||
command(['docker','start',container],f'boot{boot}-start')
|
|
||||||
deadline = time.monotonic() + 90
|
|
||||||
health = None
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if not inspect()['State']['Running']:
|
|
||||||
save_logs(f'boot{boot}-host'); raise RuntimeError('Full Host exited before health readiness.')
|
|
||||||
r = subprocess.run(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/health/ready'],text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
|
|
||||||
if r.returncode == 0:
|
|
||||||
health = r.stdout; break
|
|
||||||
time.sleep(1)
|
|
||||||
if health is None:
|
|
||||||
save_logs(f'boot{boot}-host'); raise RuntimeError('Full Host readiness did not become healthy.')
|
|
||||||
if json.loads(health).get('status') != 'Healthy': raise RuntimeError('Readiness is degraded or unhealthy.')
|
|
||||||
(run/f'boot{boot}-ready.json').write_text(health)
|
|
||||||
command(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/healthz'],f'boot{boot}-live')
|
|
||||||
command(['docker','exec',container,'curl','-fsS',f'http://127.0.0.1:{web}/health/active'],f'boot{boot}-active')
|
|
||||||
certs = list((server_pki/'own/certs').glob('*.der'))
|
|
||||||
if len(certs) != 1: raise RuntimeError('Expected exactly one application certificate.')
|
|
||||||
cert_sha256 = hashlib.sha256(certs[0].read_bytes()).hexdigest()
|
|
||||||
thumbprint = subprocess.check_output(['openssl','x509','-inform','DER','-in',str(certs[0]),'-noout','-fingerprint','-sha1'],text=True).strip()
|
|
||||||
copy_public(server_pki/'own/certs',state/'client-pki/trusted/certs')
|
|
||||||
copy_public(server_pki/'own/certs',state/'untrusted-client-pki/trusted/certs')
|
|
||||||
exec_probe(f'boot{boot}-trusted-key','/state/client-pki','allow')
|
|
||||||
exec_probe(f'boot{boot}-wrong-key','/state/client-pki','key-deny',key+'-wrong')
|
|
||||||
if boot == 1:
|
|
||||||
hold = state/'hold-server-trust'; shutil.move(str(state/'client-pki/trusted/certs'),hold)
|
|
||||||
(state/'client-pki/trusted/certs').mkdir()
|
|
||||||
try: exec_probe('untrusted-server','/state/client-pki','peer-deny')
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(state/'client-pki/trusted/certs'); shutil.move(str(hold),state/'client-pki/trusted/certs')
|
|
||||||
exec_probe('untrusted-client','/state/untrusted-client-pki','peer-deny')
|
|
||||||
r = command(['docker','exec',container,*cli,'read','--url',f'opc.tcp://localhost:{ua}/OtOpcUa','--security','encrypt','--username','apikey','--password',key,'--node','i=2259'],f'boot{boot}-actual-cli-read')
|
|
||||||
if not re.search(r'^Status:\s+Good(?:\s|$)', r.stdout, re.MULTILINE): raise RuntimeError('Actual CLI did not report a Good server status read.')
|
|
||||||
command(['docker','stop','--time','30',container],f'boot{boot}-stop',timeout=45)
|
|
||||||
stopped = inspect()['State']; save_logs(f'boot{boot}-host')
|
|
||||||
if stopped['Running'] or stopped['ExitCode'] != 0 or stopped['OOMKilled']: raise RuntimeError('Full Host did not shut down cleanly.')
|
|
||||||
summary['boots'].append({'boot':boot,'certificate_sha256':cert_sha256,'thumbprint':thumbprint,'exit':stopped['ExitCode'],'oomKilled':stopped['OOMKilled'],'health_ready':health})
|
|
||||||
if summary['boots'][0]['thumbprint'] != summary['boots'][1]['thumbprint']: raise RuntimeError('Application certificate changed between boots.')
|
|
||||||
summary['certificate_preserved'] = True
|
|
||||||
for path in rolling_root.glob('*'):
|
|
||||||
if not path.is_file(): continue
|
|
||||||
text = path.read_bytes()[rolling_offsets.get(path, 0):].decode(errors='replace')
|
|
||||||
raw_secret_findings.append({'log': 'rolling-' + path.name, 'occurrences': sum(text.count(secret) for secret in redactions)})
|
|
||||||
(run / ('rolling-' + path.name)).write_text(clean(text))
|
|
||||||
if len(summary['boots']) != 2 or len(summary['cases']) != 6: raise RuntimeError('Required smoke cases did not all execute.')
|
|
||||||
if any(x['occurrences'] for x in raw_secret_findings): raise RuntimeError('Task secret appeared in raw application output; see recorded occurrence counts.')
|
|
||||||
summary['result'] = 'passed'
|
|
||||||
finally:
|
|
||||||
if container:
|
|
||||||
save_logs('host-final')
|
|
||||||
owned = inspect()
|
|
||||||
if owned['Config']['Labels'].get('codex.task') != 'opcua-publish-smoke': raise RuntimeError('Task container ownership changed.')
|
|
||||||
command(['docker','rm','-f',container],'container-cleanup')
|
|
||||||
logs = '\n'.join(x.read_text(errors='replace') for x in run.glob('*.log'))
|
|
||||||
summary['task_secret_occurrences_in_saved_logs'] = sum(logs.count(secret) for secret in redactions)
|
|
||||||
summary['raw_log_secret_findings'] = raw_secret_findings
|
|
||||||
summary['completed_utc'] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
||||||
(run/'summary.json').write_text(json.dumps(summary,indent=2))
|
|
||||||
envfile.unlink(missing_ok=True)
|
|
||||||
print(json.dumps(summary,indent=2))
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
#pragma warning disable OTOPCUA0001 // This wire harness deliberately exercises public driver capabilities directly.
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
||||||
using AlarmEventArgs = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.AlarmEventArgs;
|
|
||||||
using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
|
||||||
using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode;
|
|
||||||
using CoreHistory = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
||||||
|
|
||||||
internal static class ProcessHost
|
|
||||||
{
|
|
||||||
static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
const string RawUri = "https://zb.com/otopcua/raw", UnsUri = "https://zb.com/otopcua/uns";
|
|
||||||
const string Folder = "Matrix/dev", Scalar = Folder + "/Scalar", Vector = Folder + "/Vector", Alarm = Folder + "/Alarm";
|
|
||||||
static readonly CancellationToken Ct = new CancellationTokenSource(TimeSpan.FromMinutes(4)).Token;
|
|
||||||
static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing ephemeral credential");
|
|
||||||
static readonly List<object> Cases = [];
|
|
||||||
static readonly List<object> Observations = [];
|
|
||||||
static int Failures;
|
|
||||||
static string Root = "";
|
|
||||||
static object? Clr(DataValue v)
|
|
||||||
{
|
|
||||||
#if SDK2
|
|
||||||
return ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(v.WrappedValue);
|
|
||||||
#else
|
|
||||||
return v.Value;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
static DateTime SourceTime(DataValue value)
|
|
||||||
{
|
|
||||||
#if SDK2
|
|
||||||
return value.SourceTimestamp.ToDateTime();
|
|
||||||
#else
|
|
||||||
return value.SourceTimestamp;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
static DateTime ServerTime(DataValue value)
|
|
||||||
{
|
|
||||||
#if SDK2
|
|
||||||
return value.ServerTimestamp.ToDateTime();
|
|
||||||
#else
|
|
||||||
return value.ServerTimestamp;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
static void Require(bool yes, string message) { if (!yes) throw new InvalidOperationException(message); }
|
|
||||||
static async Task Case(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() }); }
|
|
||||||
}
|
|
||||||
static async Task Wait(Func<bool> predicate)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, Ct); }
|
|
||||||
Require(false, "Notification deadline exceeded");
|
|
||||||
}
|
|
||||||
static ConnectionSettings Settings(string endpoint) => new()
|
|
||||||
{
|
|
||||||
EndpointUrl = endpoint,
|
|
||||||
Username = "apikey",
|
|
||||||
Password = Password,
|
|
||||||
SecurityMode = SharedSecurityMode.SignAndEncrypt,
|
|
||||||
AutoAcceptCertificates = false,
|
|
||||||
CertificateStorePath = Environment.GetEnvironmentVariable("OPCUA_MATRIX_CLIENT_PKI") ?? Path.Combine(Root, "client-pki")
|
|
||||||
};
|
|
||||||
public static async Task<int> Main(string[] args)
|
|
||||||
{
|
|
||||||
Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
switch (args[0])
|
|
||||||
{
|
|
||||||
case "local-data-path": Console.WriteLine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); return 0;
|
|
||||||
case "prepare":
|
|
||||||
var type = typeof(OpcUaClientService).Assembly.GetType("ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters.DefaultApplicationConfigurationFactory")!;
|
|
||||||
var factory = Activator.CreateInstance(type, true)!;
|
|
||||||
await (Task<ApplicationConfiguration>)type.GetMethod("CreateAsync")!.Invoke(factory, [Settings("opc.tcp://127.0.0.1:1"), Ct])!;
|
|
||||||
Console.WriteLine("PREPARED"); return 0;
|
|
||||||
case "prepare-driver": await PrepareDriver(); return 0;
|
|
||||||
case "driver": await Driver(args[2]); break;
|
|
||||||
case "server": await Serve(); return 0;
|
|
||||||
case "client": await Client(args[2]); break;
|
|
||||||
case "performance": await Performance(args[2]); break;
|
|
||||||
default: throw new ArgumentException("Expected prepare/server/client");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception e) { Failures++; Cases.Add(new { name = "process", status = "failed", error = e.ToString() }); }
|
|
||||||
File.WriteAllText(Path.Combine(Root, "result.json"), JsonSerializer.Serialize(new { sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), process = Environment.ProcessId, cases = Cases, observations = Observations, failures = Failures, loadedSdkAssemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name!.StartsWith("Opc.Ua", StringComparison.Ordinal)).Select(a => new { name = a.GetName().Name, version = a.GetName().Version?.ToString(), path = a.Location }) }, new JsonSerializerOptions { WriteIndented = true }));
|
|
||||||
return Failures == 0 ? 0 : 1;
|
|
||||||
}
|
|
||||||
static async Task Serve()
|
|
||||||
{
|
|
||||||
var options = new OpcUaApplicationHostOptions
|
|
||||||
{
|
|
||||||
ApplicationName = "OtOpcUa.Matrix",
|
|
||||||
ApplicationUri = $"urn:OtOpcUa.Matrix.{Guid.NewGuid():N}",
|
|
||||||
PublicHostname = "127.0.0.1",
|
|
||||||
PkiStoreRoot = Path.Combine(Root, "server-pki"),
|
|
||||||
AllowAnonymous = false,
|
|
||||||
AutoAcceptUntrustedClientCertificates = false,
|
|
||||||
EnabledSecurityProfiles = Environment.GetEnvironmentVariable("OPCUA_MATRIX_SERVER_SECURITY") == "none"
|
|
||||||
? [OpcUaSecurityProfile.None] : [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
|
|
||||||
};
|
|
||||||
using (var listener = new TcpListener(IPAddress.Loopback, 0)) { listener.Start(); options.OpcUaPort = ((IPEndPoint)listener.LocalEndpoint).Port; }
|
|
||||||
var server = new OtOpcUaSdkServer();
|
|
||||||
await using var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance, new Auth());
|
|
||||||
await host.StartAsync(server, Ct);
|
|
||||||
var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm);
|
|
||||||
var authority = new DataPlaneAuthorityState();
|
|
||||||
authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
|
|
||||||
[new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
||||||
nm.DataPlaneAuthority = authority;
|
|
||||||
nm.Authorizer = new DataPlaneAuthorizer(authority, new TriePermissionEvaluator(authority.Tries), true, NullLogger.Instance, TimeProvider.System);
|
|
||||||
var history = new Source(Root); nm.HistorianDataSource = history;
|
|
||||||
sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw);
|
|
||||||
sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns);
|
|
||||||
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
|
|
||||||
{
|
|
||||||
var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar";
|
|
||||||
var vector = realm == AddressSpaceRealm.Raw ? Vector : "Eq/Vector";
|
|
||||||
var parent = realm == AddressSpaceRealm.Raw ? Folder : "Eq";
|
|
||||||
sink.EnsureVariable(scalar, parent, "Scalar", "Int32", true, realm, "matrix.scalar");
|
|
||||||
sink.EnsureVariable(vector, parent, "Vector", "Int32", true, realm, isArray: true, arrayLength: 3);
|
|
||||||
sink.WriteValue(scalar, 17, 0, Epoch, realm); sink.WriteValue(vector, new[] { 1, 2, 3 }, 0, Epoch, realm);
|
|
||||||
}
|
|
||||||
sink.AddReference("Eq/Scalar", AddressSpaceRealm.Uns, Scalar, AddressSpaceRealm.Raw);
|
|
||||||
sink.MaterialiseAlarmCondition(Alarm, Folder, "Alarm", "OffNormalAlarm", 800, AddressSpaceRealm.Raw, isNative: true);
|
|
||||||
sink.WireAlarmNotifiers(Alarm, AddressSpaceRealm.Raw, ["Eq"], AddressSpaceRealm.Uns);
|
|
||||||
sink.EnsureVariable(Folder + "/UnsetTimestamp", Folder, "UnsetTimestamp", "Int32", false, AddressSpaceRealm.Raw);
|
|
||||||
sink.WriteValue(Folder + "/UnsetTimestamp", 3, 0, DateTime.MinValue, AddressSpaceRealm.Raw);
|
|
||||||
nm.NodeWriteGateway = new Gateway();
|
|
||||||
var state = new AlarmConditionSnapshot(true, false, true, true, AlarmShelvingKind.Unshelved, 800, "matrix-alarm");
|
|
||||||
nm.NativeAlarmAckRouter = _ => Task.Run(() => { state = state with { Acknowledged = true }; sink.WriteAlarmCondition(Alarm, state, DateTime.UtcNow, AddressSpaceRealm.Raw); });
|
|
||||||
File.WriteAllText(Path.Combine(Root, "endpoint.txt"), $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa");
|
|
||||||
File.WriteAllText(Path.Combine(Root, "ready.json"), JsonSerializer.Serialize(new { endpoint = $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa", process = Environment.ProcessId, sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), rawUri = RawUri, unsUri = UnsUri }));
|
|
||||||
int tick = 0;
|
|
||||||
while (!File.Exists(Path.Combine(Root, "stop")))
|
|
||||||
{
|
|
||||||
sink.WriteAlarmCondition(Alarm, state with { Message = ++tick % 2 == 0 ? "matrix-alarm" : "matrix-tick" }, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
||||||
await Task.Delay(1000, Ct);
|
|
||||||
}
|
|
||||||
using var process = System.Diagnostics.Process.GetCurrentProcess();
|
|
||||||
File.WriteAllText(Path.Combine(Root, "server-resources.json"), JsonSerializer.Serialize(new { process.WorkingSet64, process.PrivateMemorySize64, process.HandleCount, threads=process.Threads.Count, cpuMilliseconds=process.TotalProcessorTime.TotalMilliseconds, allocatedBytes=GC.GetTotalAllocatedBytes(), collections=new[]{GC.CollectionCount(0),GC.CollectionCount(1),GC.CollectionCount(2)} }, new JsonSerializerOptions {WriteIndented=true}));
|
|
||||||
}
|
|
||||||
static async Task Client(string endpoint)
|
|
||||||
{
|
|
||||||
using var client = new OpcUaClientService();
|
|
||||||
await Case("trusted-SignAndEncrypt-API-key", async () => { var info = await client.ConnectAsync(Settings(endpoint), Ct); Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected negotiated policy"); Observations.Add(new { info.SecurityMode, info.SecurityPolicyUri }); });
|
|
||||||
if (!client.IsConnected) return;
|
|
||||||
var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!;
|
|
||||||
var raw = (ushort)Array.IndexOf(namespaces, RawUri); var uns = (ushort)Array.IndexOf(namespaces, UnsUri);
|
|
||||||
NodeId ScalarId(ushort ns) => new(ns == raw ? Scalar : "Eq/Scalar", ns);
|
|
||||||
NodeId VectorId(ushort ns) => new(ns == raw ? Vector : "Eq/Vector", ns);
|
|
||||||
await Case("raw-UNS-URI-browse", async () =>
|
|
||||||
{
|
|
||||||
Require(raw > 0 && uns > 0, "Missing namespace URI");
|
|
||||||
var rawChildren = await client.BrowseAsync(new NodeId(Folder, raw), Ct);
|
|
||||||
var unsChildren = await client.BrowseAsync(new NodeId("Eq", uns), Ct);
|
|
||||||
Observations.Add(new { rawChildren, unsChildren });
|
|
||||||
// Supplement the production Shared results with unfiltered wire-reference metadata.
|
|
||||||
var adapter = typeof(OpcUaClientService).GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(client)!;
|
|
||||||
var sdkSession = (Opc.Ua.Client.Session)adapter.GetType().GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(adapter)!;
|
|
||||||
var (_, _, rawReferences) = await sdkSession.BrowseAsync(null, null, new NodeId(Folder, raw), 0u,
|
|
||||||
BrowseDirection.Forward, ReferenceTypeIds.References, true, 0u);
|
|
||||||
Observations.Add(new { rawReferences = (rawReferences.ToArray() ?? []).Select(reference => new { nodeId = reference.NodeId.ToString(), browseName = reference.BrowseName.ToString(), nodeClass = reference.NodeClass.ToString(), typeDefinition = reference.TypeDefinition.ToString(), referenceType = reference.ReferenceTypeId.ToString(), reference.IsForward }).ToArray() });
|
|
||||||
var expectedRaw = new[] { Scalar, Vector, Alarm, Folder + "/UnsetTimestamp" }.Select(id => new NodeId(id, raw).ToString());
|
|
||||||
Require(expectedRaw.All(id => rawChildren.Any(child => child.NodeId == id)), "Missing raw children: " + string.Join(",", expectedRaw.Where(id => !rawChildren.Any(child => child.NodeId == id))));
|
|
||||||
Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children");
|
|
||||||
});
|
|
||||||
await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); });
|
|
||||||
foreach (var ns in new[] { raw, uns })
|
|
||||||
{
|
|
||||||
await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); });
|
|
||||||
await Case($"typed-array-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(VectorId(ns), Ct)) is int[], "Array lost Int32[] type"); Require(StatusCode.IsGood(await client.WriteValueAsync(VectorId(ns), new[] { 7, 8, 9 }, Ct)), "Array write failed"); Require(((int[])Clr(await client.ReadValueAsync(VectorId(ns), Ct))!).SequenceEqual(new[] { 7, 8, 9 }), "Array round trip failed"); });
|
|
||||||
}
|
|
||||||
await Case("monitor-quality-timestamps", async () => { var seen = new ConcurrentQueue<DataValue>(); client.DataChanged += (_, e) => seen.Enqueue(e.Value); await client.SubscribeAsync(ScalarId(raw), 100, Ct); await client.WriteValueAsync(ScalarId(raw), 73, Ct); await Wait(() => seen.Any(v => Equals(Clr(v), 73))); var v = seen.Last(v => Equals(Clr(v), 73)); Require(StatusCode.IsGood(v.StatusCode) && SourceTime(v) > Epoch && ServerTime(v) > Epoch, "Bad monitor quality or missing timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(v).ToString("O"), serverTimestamp = ServerTime(v).ToString("O"), status = v.StatusCode.ToString() }); await client.UnsubscribeAsync(ScalarId(raw), Ct); });
|
|
||||||
await Case("event-filter-and-native-ack", async () => { var alarms = new ConcurrentQueue<AlarmEventArgs>(); client.AlarmEvent += (_, a) => alarms.Enqueue(a); await client.SubscribeAlarmsAsync(ObjectIds.Server, 100, Ct); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.ActiveState && a.Severity == 800)); var alarm = alarms.Last(a => a.Message == "matrix-alarm"); Require(alarm.EventId?.Length > 0, "Missing event ID"); Require(StatusCode.IsGood(await client.AcknowledgeAlarmAsync(new NodeId(Alarm, raw).ToString(), alarm.EventId!, "matrix-ack", Ct)), "Ack refused"); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.AckedState)); await client.UnsubscribeAlarmsAsync(Ct); });
|
|
||||||
await Case("raw-history-CP-paging", async () => { var values = await client.HistoryReadRawAsync(ScalarId(raw), Epoch, Epoch.AddMinutes(1), 100, Ct); Require(values.Count == 25, "Expected 25 raw samples, got " + values.Count); Require(values.Select(v => Convert.ToInt32(Clr(v))).SequenceEqual(Enumerable.Range(0, 25)), "Raw page loss/duplicates/order"); });
|
|
||||||
await Case("event-history-CP-paging", async () =>
|
|
||||||
{
|
|
||||||
var filter = new EventFilter();
|
|
||||||
#if SDK2
|
|
||||||
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value };
|
|
||||||
#else
|
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand {TypeDefinitionId=ObjectTypeIds.BaseEventType,BrowsePath=new QualifiedNameCollection {new QualifiedName("EventId")},AttributeId=Attributes.Value});
|
|
||||||
#endif
|
|
||||||
var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct); Require(events.Count == 25, "Expected 25 event samples, got " + events.Count);
|
|
||||||
#if SDK2
|
|
||||||
var ids = events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Fields[0])!));
|
|
||||||
#else
|
|
||||||
var ids=events.Select(e=>System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value));
|
|
||||||
#endif
|
|
||||||
Require(ids.SequenceEqual(Enumerable.Range(0, 25).Select(i => $"e{i:00}")), "Event history loss/duplicates/order across timestamp ties");
|
|
||||||
});
|
|
||||||
await Case("event-history-where-filter-positive-and-negative", async () =>
|
|
||||||
{
|
|
||||||
foreach (var type in new[] { ObjectTypeIds.BaseEventType, ObjectTypeIds.AuditEventType })
|
|
||||||
{
|
|
||||||
var filter = new EventFilter();
|
|
||||||
#if SDK2
|
|
||||||
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value };
|
|
||||||
filter.WhereClause.Push(FilterOperator.OfType, Variant.From(type));
|
|
||||||
#else
|
|
||||||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType,new QualifiedName("EventId")){AttributeId=Attributes.Value});
|
|
||||||
filter.WhereClause.Push(FilterOperator.OfType,new LiteralOperand(new Variant(type)));
|
|
||||||
#endif
|
|
||||||
var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct);
|
|
||||||
Require(events.Count == (type == ObjectTypeIds.BaseEventType ? 25 : 0), $"Where filter for {type} expected {(type == ObjectTypeIds.BaseEventType ? 25 : 0)} got {events.Count}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var unset = await client.ReadValueAsync(new NodeId(Folder + "/UnsetTimestamp", raw), Ct);
|
|
||||||
Observations.Add(new { sourceTimestamp = SourceTime(unset).ToString("O"), serverTimestamp = ServerTime(unset).ToString("O"), timestampObservation = "Fixture source supplied DateTime.MinValue (unset). These are unmodified application/SDK wire observations; no historian algorithm or expected timestamp was changed." });
|
|
||||||
await Benchmark(client, ScalarId(raw), VectorId(raw));
|
|
||||||
await client.DisconnectAsync(Ct);
|
|
||||||
}
|
|
||||||
static OpcUaClientDriverOptions DriverOptions(string endpoint) => new()
|
|
||||||
{
|
|
||||||
EndpointUrl = endpoint,
|
|
||||||
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
|
|
||||||
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
|
||||||
AuthType = OpcUaAuthType.Username,
|
|
||||||
Username = "apikey",
|
|
||||||
Password = Password,
|
|
||||||
AutoAcceptCertificates = false,
|
|
||||||
RawTags = [new("scalar",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Scalar}"}),true),
|
|
||||||
new("vector",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Vector}"}),true),
|
|
||||||
new("uns",JsonSerializer.Serialize(new {nodeId=$"nsu={UnsUri};s=Eq/Scalar"}),true)],
|
|
||||||
SessionName = "MatrixDriver",
|
|
||||||
ApplicationUri = "urn:localhost:MatrixDriver"
|
|
||||||
};
|
|
||||||
static string IsolatedLocalData()
|
|
||||||
{
|
|
||||||
var local = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
|
||||||
Require(local.StartsWith(Path.GetFullPath(Root) + Path.DirectorySeparatorChar, StringComparison.Ordinal), "Driver/CLI canonical PKI must be in task-owned root: " + local);
|
|
||||||
return local;
|
|
||||||
}
|
|
||||||
static async Task PrepareDriver()
|
|
||||||
{
|
|
||||||
var local = IsolatedLocalData();
|
|
||||||
await using var driver = new OpcUaClientDriver(DriverOptions("opc.tcp://127.0.0.1:1"), "matrix");
|
|
||||||
// The production configuration method creates the application's own certificate without
|
|
||||||
// attempting a session. Reflection is confined to preparation; all wire operations below
|
|
||||||
// call the unchanged public driver API.
|
|
||||||
var type = typeof(OpcUaClientDriver);
|
|
||||||
var hook = Activator.CreateInstance(type.GetNestedType("CertValidationHook", BindingFlags.NonPublic)!, true)!;
|
|
||||||
await (Task<ApplicationConfiguration>)type.GetMethod("BuildApplicationConfigurationAsync", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(driver, [hook, Ct])!;
|
|
||||||
File.WriteAllText(Path.Combine(Root, "driver-pki.txt"), Path.Combine(local, "OtOpcUa", "pki"));
|
|
||||||
}
|
|
||||||
static async Task Driver(string endpoint)
|
|
||||||
{
|
|
||||||
IsolatedLocalData();
|
|
||||||
await using var driver = new OpcUaClientDriver(DriverOptions(endpoint), "matrix");
|
|
||||||
await Case("driver-trusted-encrypted-API-key-connect", () => driver.InitializeAsync("{}", Ct));
|
|
||||||
await Case("driver-raw-UNS-URI-scalar-and-array-read", async () =>
|
|
||||||
{
|
|
||||||
var values = await driver.ReadAsync(["scalar", "vector", "uns"], Ct);
|
|
||||||
Require(values.Count == 3 && values.All(x => x.StatusCode == 0), "Read failure");
|
|
||||||
Require(values[0].Value is int && values[1].Value is int[] && values[2].Value is int, "Lost scalar/array type");
|
|
||||||
});
|
|
||||||
await Case("driver-scalar-array-write-roundtrip", async () =>
|
|
||||||
{
|
|
||||||
var writes = await driver.WriteAsync([new WriteRequest("scalar", 91), new WriteRequest("vector", new[] { 11, 12, 13 })], Ct);
|
|
||||||
Require(writes.All(x => x.StatusCode == 0), "Write failed");
|
|
||||||
var values = await driver.ReadAsync(["scalar", "vector"], Ct);
|
|
||||||
Require(Equals(values[0].Value, 91) && ((int[])values[1].Value!).SequenceEqual(new[] { 11, 12, 13 }), "Write round trip mismatch");
|
|
||||||
});
|
|
||||||
await Case("driver-monitor-quality-timestamps", async () =>
|
|
||||||
{
|
|
||||||
var seen = new ConcurrentQueue<DataValueSnapshot>();
|
|
||||||
driver.OnDataChange += (_, e) => seen.Enqueue(e.Snapshot);
|
|
||||||
var subscription = await driver.SubscribeAsync(["scalar"], TimeSpan.FromMilliseconds(100), Ct);
|
|
||||||
await driver.WriteAsync([new WriteRequest("scalar", 92)], Ct);
|
|
||||||
await Wait(() => seen.Any(x => Equals(x.Value, 92)));
|
|
||||||
var value = seen.Last(x => Equals(x.Value, 92));
|
|
||||||
Require(value.StatusCode == 0 && value.SourceTimestampUtc.HasValue && value.ServerTimestampUtc > Epoch, "Missing monitor quality/timestamps");
|
|
||||||
Observations.Add(new { value.SourceTimestampUtc, value.ServerTimestampUtc, value.StatusCode });
|
|
||||||
await driver.UnsubscribeAsync(subscription, Ct);
|
|
||||||
});
|
|
||||||
Observations.Add(new { capability = "driver-history", status = "not-applicable", evidence = "Driver has no IHistoryProvider/HistoryRead capability; .claude/rules/drivers.md #655. Shared/CLI own history." });
|
|
||||||
await driver.ShutdownAsync(Ct);
|
|
||||||
}
|
|
||||||
static async Task Performance(string endpoint)
|
|
||||||
{
|
|
||||||
using var client = new OpcUaClientService();
|
|
||||||
var info = await client.ConnectAsync(Settings(endpoint), Ct);
|
|
||||||
Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected benchmark endpoint policy");
|
|
||||||
var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!;
|
|
||||||
var raw = (ushort)Array.IndexOf(namespaces, RawUri);
|
|
||||||
Require(raw > 0, "Missing benchmark raw namespace");
|
|
||||||
Observations.Add(new {info.SecurityMode, info.SecurityPolicyUri, tieredCompilation=Environment.GetEnvironmentVariable("DOTNET_TieredCompilation"), scope="Two scalar/Int32[3] variables; zero data monitors during measurement; five fixture variables and one native condition"});
|
|
||||||
await Case("warmed-production-client-read-write-measurement", () => Benchmark(client, new NodeId(Scalar, raw), new NodeId(Vector, raw)));
|
|
||||||
await client.DisconnectAsync(Ct);
|
|
||||||
}
|
|
||||||
static async Task Benchmark(OpcUaClientService client, NodeId scalar, NodeId vector)
|
|
||||||
{
|
|
||||||
// Local diagnostic subset; excludes reconnect, history and load.
|
|
||||||
var rounds = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_ROUNDS") ?? "3");
|
|
||||||
var warmups = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_WARMUPS") ?? "10");
|
|
||||||
var measured = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_MEASURED") ?? "50");
|
|
||||||
Require(rounds > 0 && warmups >= 0 && measured > 0, "Invalid performance sample counts");
|
|
||||||
for (int round = 1; round <= rounds; round++)
|
|
||||||
{
|
|
||||||
for (int warmup = 0; warmup < warmups; warmup++)
|
|
||||||
{
|
|
||||||
await client.ReadValueAsync(scalar, Ct); await client.WriteValueAsync(scalar, warmup, Ct);
|
|
||||||
await client.ReadValueAsync(vector, Ct); await client.WriteValueAsync(vector, new[] { warmup, warmup + 1, warmup + 2 }, Ct);
|
|
||||||
}
|
|
||||||
var samples = new Dictionary<string, List<double>>();
|
|
||||||
var before = System.Diagnostics.Process.GetCurrentProcess(); var cpu = before.TotalProcessorTime;
|
|
||||||
var allocated = GC.GetTotalAllocatedBytes(); var gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) };
|
|
||||||
foreach (var operation in new[] { "scalar-read", "scalar-write", "array-read", "array-write" })
|
|
||||||
{
|
|
||||||
var times = new List<double>();
|
|
||||||
for (int i = 0; i < measured; i++)
|
|
||||||
{
|
|
||||||
var start = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
||||||
if (operation == "scalar-read") await client.ReadValueAsync(scalar, Ct);
|
|
||||||
else if (operation == "scalar-write") Require(StatusCode.IsGood(await client.WriteValueAsync(scalar, i, Ct)), "Benchmark scalar write");
|
|
||||||
else if (operation == "array-read") await client.ReadValueAsync(vector, Ct);
|
|
||||||
else Require(StatusCode.IsGood(await client.WriteValueAsync(vector, new[] { i, i + 1, i + 2 }, Ct)), "Benchmark array write");
|
|
||||||
times.Add(System.Diagnostics.Stopwatch.GetElapsedTime(start).TotalMilliseconds);
|
|
||||||
}
|
|
||||||
samples[operation] = times;
|
|
||||||
}
|
|
||||||
before.Refresh();
|
|
||||||
Observations.Add(new
|
|
||||||
{
|
|
||||||
benchmarkRound = round,
|
|
||||||
warmupOperationsPerKind = 10,
|
|
||||||
operationsPerKind = 50,
|
|
||||||
latencyMilliseconds = samples.ToDictionary(x => x.Key, x => new { p50 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.50) - 1), p95 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.95) - 1), p99 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.99) - 1), throughputOperationsPerSecond = measured * 1000.0 / x.Value.Sum() }),
|
|
||||||
clientCpuMilliseconds = (before.TotalProcessorTime - cpu).TotalMilliseconds,
|
|
||||||
clientAllocatedBytes = GC.GetTotalAllocatedBytes() - allocated, clientAllocatedBytesPerOperation = (GC.GetTotalAllocatedBytes() - allocated) / (4.0 * measured),
|
|
||||||
clientWorkingSetBytes = before.WorkingSet64,
|
|
||||||
clientPrivateBytes = before.PrivateMemorySize64,
|
|
||||||
clientThreads = before.Threads.Count,
|
|
||||||
clientHandles = before.HandleCount,
|
|
||||||
collections = new[] { GC.CollectionCount(0) - gc[0], GC.CollectionCount(1) - gc[1], GC.CollectionCount(2) - gc[2] }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sealed class Auth : IOpcUaUserAuthenticator
|
|
||||||
{
|
|
||||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromResult(username == "apikey" && password == Password ? OpcUaUserAuthResult.AllowKey("matrix", [OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck], "matrix") : OpcUaUserAuthResult.Deny("Invalid ephemeral fixture key"));
|
|
||||||
}
|
|
||||||
sealed class Gateway : IOpcUaNodeWriteGateway { public Task<NodeWriteOutcome> WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(true, null)); }
|
|
||||||
sealed class Source(string root) : IHistorianDataSource
|
|
||||||
{
|
|
||||||
void Log(string operation, int rows) => File.AppendAllText(Path.Combine(root, "history-requests.jsonl"), JsonSerializer.Serialize(new { operation, rows }) + "\n");
|
|
||||||
public int MaxRawValuesPerRead => 7;
|
|
||||||
public Task<CoreHistory> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct)
|
|
||||||
{ var all = Enumerable.Range(0, 25).Select(i => new DataValueSnapshot(i, 0, Epoch.AddSeconds(i), Epoch.AddSeconds(i))).Where(v => v.SourceTimestampUtc >= s && v.SourceTimestampUtc < e).ToList(); var rows = all.Take((int)(m == 0 ? 7 : m)).ToArray(); Log("raw", rows.Length); return Task.FromResult(new CoreHistory(rows, all.Count > rows.Length ? [1] : null)); }
|
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? source, DateTime s, DateTime e, int max, CancellationToken ct)
|
|
||||||
{ var all = Enumerable.Range(0, 25).Select(i => new HistoricalEvent($"e{i:00}", "matrix", Epoch.AddSeconds(i / 2), Epoch, "matrix-history", 500)).Where(v => v.EventTimeUtc >= s && v.EventTimeUtc < e).ToList(); var rows = all.Take(Math.Min(7, max <= 0 ? 7 : max)).ToArray(); Log("events", rows.Length); return Task.FromResult(new HistoricalEventsResult(rows, all.Count > rows.Length ? [1] : null)); }
|
|
||||||
public Task<CoreHistory> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
|
||||||
public Task<CoreHistory> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
|
||||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# OPC UA SDK process compatibility
|
|
||||||
|
|
||||||
Run from a dedicated candidate checkout with .NET 10, Python 3, PowerShell 7,
|
|
||||||
and Docker. Supply the unchanged original application's existing `host/` and
|
|
||||||
`client/` publish directories. The output directory must not already exist.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
./scripts/e2e/test-opcua-sdk-compatibility.ps1 `
|
|
||||||
-BaselineRoot /absolute/original-checkout `
|
|
||||||
-BaselineArtifacts /absolute/original-publishes `
|
|
||||||
-CandidateRoot /absolute/dedicated-candidate-checkout `
|
|
||||||
-OutputDirectory /absolute/fresh-evidence-directory
|
|
||||||
```
|
|
||||||
|
|
||||||
The baseline console references the exact published production assemblies;
|
|
||||||
it does not rebuild or edit the original application. The candidate console
|
|
||||||
references the production host, Client.Shared and OPC UA driver projects, and
|
|
||||||
the candidate CLI is separately published. Different SDK versions always run
|
|
||||||
in different processes. Source revisions, SDK assemblies, SHA-256 hashes,
|
|
||||||
commands, per-case results, actual namespace URIs, negotiated Shared security,
|
|
||||||
loaded client SDK assemblies, timestamps and backend request logs are retained.
|
|
||||||
|
|
||||||
The required Shared legs are original→candidate, candidate→original and
|
|
||||||
candidate→candidate. A fourth original→original leg provides a reference for
|
|
||||||
both the application contract and the small performance sample. Every Shared
|
|
||||||
leg checks the exact scalar, array and condition browse identities, both
|
|
||||||
namespaces by URI, typed scalar/Int32[3] read/write, monitored Good quality and
|
|
||||||
source/server timestamps, the production alarm event filter and native
|
|
||||||
acknowledgment, and complete raw/event history across continuation points.
|
|
||||||
Event-history results include timestamp ties, exact IDs/order and positive and
|
|
||||||
negative `OfType` predicates. The historian is a deterministic fixture behind
|
|
||||||
the production server's session-bound continuation store; these results do
|
|
||||||
not certify any SQL Server, QuestDB or AVEVA backend.
|
|
||||||
|
|
||||||
Server, Shared client and driver require trusted Basic256Sha256
|
|
||||||
SignAndEncrypt peers, with certificate autoaccept disabled. Public certificates
|
|
||||||
are exchanged before sessions open. The test authenticator accepts one exact,
|
|
||||||
random, API-key-shaped credential from the process environment; production
|
|
||||||
ACL, write and condition-method checks use the resulting key identity. This
|
|
||||||
fixture does not replace cryptographic API-key-authenticator coverage.
|
|
||||||
|
|
||||||
The driver and actual CLI run in a disposable `--network none` Linux container,
|
|
||||||
alongside a separate baseline or candidate server process. PKI lives only in
|
|
||||||
the mounted task directory; no host profile PKI or fleet endpoint is touched.
|
|
||||||
The driver hardcodes `LocalApplicationData/OtOpcUa/pki`, so Linux's task-local
|
|
||||||
`XDG_DATA_HOME` supplies isolation. The helper refuses a driver store outside
|
|
||||||
its task root. CLI uses its own task-local canonical store. CLI currently
|
|
||||||
forces `AutoAcceptCertificates=true` and exposes no strict-validation flag
|
|
||||||
(`CommandBase.CreateConnectionSettings`); the exchanged peers and encrypted
|
|
||||||
server remain in use, but CLI is not counted as strict client validation.
|
|
||||||
Driver history is N/A because the application removed that capability (#655);
|
|
||||||
Shared and CLI execute history reads instead.
|
|
||||||
|
|
||||||
Three measured rounds follow ten warmup operations per kind in each round:
|
|
||||||
50 scalar reads, scalar writes, array reads and array writes per kind. The
|
|
||||||
sample records nearest-rank p50/p95/p99, sequential throughput, client CPU,
|
|
||||||
allocated bytes, GC collections, working set, private bytes, threads and
|
|
||||||
handles. This is a small local diagnostic on a shared development host. It
|
|
||||||
has no production fan-out, reconnect, queue, backpressure, backend or soak
|
|
||||||
performance claim and does not establish Gate G4.
|
|
||||||
|
|
||||||
A separately published `server` helper can be used by other local consumer
|
|
||||||
checks. Supply a fresh directory and `OPCUA_MATRIX_PASSWORD` in the process
|
|
||||||
environment, then launch `dotnet ProcessHost.dll server <directory>`.
|
|
||||||
`ready.json`/`endpoint.txt` identify the ephemeral loopback port; touching
|
|
||||||
`<directory>/stop` shuts it down. Its default is strict SignAndEncrypt only.
|
|
||||||
`OPCUA_MATRIX_SERVER_SECURITY=none` is an explicit fixture-only override for
|
|
||||||
checking an existing unsecured consumer subset; the matrix runner clears that
|
|
||||||
override. Anonymous is disabled in either mode. Secure clients must place
|
|
||||||
their public certificates under `server-pki/trusted/certs` before server start
|
|
||||||
and trust the resulting `server-pki/own/certs` certificate.
|
|
||||||
|
|
||||||
Explicitly supplied timestamps are asserted. Unset source timestamps are
|
|
||||||
recorded separately as observations, with their original application/SDK wire
|
|
||||||
values. The harness does not change historian algorithms or normalize the
|
|
||||||
unset-timestamp sentinel to manufacture parity.
|
|
||||||
|
|
||||||
For the supplementary sustained warmup experiment, use the built consoles from
|
|
||||||
one completed matrix run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 scripts/validation/opcua-sdk-compatibility/performance.py \
|
|
||||||
--baseline-harness /absolute/run/build-baseline/bin/Release/net10.0/ProcessHost.dll \
|
|
||||||
--candidate-harness /absolute/run/build-candidate/bin/Release/net10.0/ProcessHost.dll \
|
|
||||||
--output /absolute/fresh-performance-directory
|
|
||||||
```
|
|
||||||
|
|
||||||
This runs three fresh baseline/candidate process pairs serially, with 1,000
|
|
||||||
warmup and 2,000 measured operations per kind in each process. Both server and
|
|
||||||
client receive `DOTNET_TieredCompilation=0` for this experiment only; the
|
|
||||||
application's shipped configuration is unchanged. The recorded budget is a
|
|
||||||
15% increase in the median of the three p95 samples for any operation kind.
|
|
||||||
Exit code 2 means a diagnostic budget regression, while process failures
|
|
||||||
remain errors. Even an experiment within that budget does not certify the
|
|
||||||
complete production performance matrix. The allocation-per-operation field
|
|
||||||
is the average across all four operation kinds in a measured workload.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Runs only inside the disposable, network-isolated task container.
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p /task/driver/data /task/cli/data /task/server
|
|
||||||
XDG_DATA_HOME=/task/driver/data dotnet /candidate/ProcessHost.dll prepare-driver /task/driver > /task/driver-prepare.log 2>&1
|
|
||||||
OPCUA_MATRIX_CLIENT_PKI=/task/cli/data/OtOpcUaClient/pki XDG_DATA_HOME=/task/cli/data dotnet /candidate/ProcessHost.dll prepare /task/cli > /task/cli-prepare.log 2>&1
|
|
||||||
mkdir -p /task/server/server-pki/trusted/certs
|
|
||||||
cp /task/driver/data/OtOpcUa/pki/own/certs/* /task/server/server-pki/trusted/certs/
|
|
||||||
cp /task/cli/data/OtOpcUaClient/pki/own/certs/* /task/server/server-pki/trusted/certs/
|
|
||||||
dotnet /server/ProcessHost.dll server /task/server > /task/server.log 2>&1 &
|
|
||||||
server_pid=$!
|
|
||||||
cleanup() {
|
|
||||||
touch /task/server/stop
|
|
||||||
wait "$server_pid" || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
for attempt in $(seq 1 600); do
|
|
||||||
test ! -f /task/server/ready.json || break
|
|
||||||
kill -0 "$server_pid" || exit 1
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
test -f /task/server/ready.json
|
|
||||||
endpoint=$(cat /task/server/endpoint.txt)
|
|
||||||
mkdir -p /task/driver/data/OtOpcUa/pki/trusted/certs /task/cli/data/OtOpcUaClient/pki/trusted/certs
|
|
||||||
cp /task/server/server-pki/own/certs/* /task/driver/data/OtOpcUa/pki/trusted/certs/
|
|
||||||
cp /task/server/server-pki/own/certs/* /task/cli/data/OtOpcUaClient/pki/trusted/certs/
|
|
||||||
result=0
|
|
||||||
XDG_DATA_HOME=/task/driver/data dotnet /candidate/ProcessHost.dll driver /task/driver "$endpoint" > /task/driver.log 2>&1 || result=1
|
|
||||||
# CLI currently autoaccepts by product design; it has no strict-validation flag.
|
|
||||||
# Its public certificates are nevertheless explicitly exchanged in this fixture.
|
|
||||||
XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll read --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev/Scalar' > /task/cli-read.log 2>&1 || result=1
|
|
||||||
XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll historyread --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev/Scalar' --start 2026-10-01T00:00:00Z --end 2026-10-01T00:01:00Z --max 100 > /task/cli-raw-history.log 2>&1 || result=1
|
|
||||||
XDG_DATA_HOME=/task/cli/data dotnet /cli/ZB.MOM.WW.OtOpcUa.Client.CLI.dll historyread --url "$endpoint" --security signandencrypt --username apikey --password "$OPCUA_MATRIX_PASSWORD" --node 'ns=2;s=Matrix/dev' --start 2026-10-01T00:00:00Z --end 2026-10-01T00:01:00Z --max 100 --events --fields EventId,Message > /task/cli-event-history.log 2>&1 || result=1
|
|
||||||
exit "$result"
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Supplementary warmed, matched native-process experiment; not full Gate G4."""
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import secrets
|
|
||||||
import shutil
|
|
||||||
import statistics
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument('--baseline-harness', type=Path, required=True)
|
|
||||||
parser.add_argument('--candidate-harness', type=Path, required=True)
|
|
||||||
parser.add_argument('--output', type=Path, required=True)
|
|
||||||
parser.add_argument('--tiered-compilation', choices=['0', '1'], default='0')
|
|
||||||
args = parser.parse_args()
|
|
||||||
output = args.output.resolve()
|
|
||||||
output.mkdir(parents=True, exist_ok=False)
|
|
||||||
env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32),
|
|
||||||
DOTNET_TieredCompilation=args.tiered_compilation, OPCUA_MATRIX_PERF_ROUNDS='1',
|
|
||||||
OPCUA_MATRIX_PERF_WARMUPS='1000', OPCUA_MATRIX_PERF_MEASURED='2000')
|
|
||||||
for key in ('OPCUA_MATRIX_SERVER_SECURITY', 'OPCUA_MATRIX_CLIENT_PKI'):
|
|
||||||
env.pop(key, None)
|
|
||||||
metadata = {'scope': __doc__, 'environment': {k: env[k] for k in ('DOTNET_TieredCompilation', 'OPCUA_MATRIX_PERF_ROUNDS', 'OPCUA_MATRIX_PERF_WARMUPS', 'OPCUA_MATRIX_PERF_MEASURED')},
|
|
||||||
'dotnetSdk': subprocess.check_output(['dotnet', '--version'], text=True).strip(), 'p95BudgetPercent': 15,
|
|
||||||
'dateUtc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'logicalCpuCount': os.cpu_count()}
|
|
||||||
commands, results, hashes = [], [], []
|
|
||||||
|
|
||||||
def write(path, data):
|
|
||||||
path.write_text(json.dumps(data, indent=2) + '\n')
|
|
||||||
|
|
||||||
def run(command, log):
|
|
||||||
commands.append({'argv': list(map(str, command)), 'log': str(log)})
|
|
||||||
with log.open('w') as stream:
|
|
||||||
result = subprocess.run(list(map(str, command)), stdout=stream, stderr=subprocess.STDOUT, env=env, timeout=240)
|
|
||||||
if result.returncode:
|
|
||||||
raise RuntimeError(f'Process exited {result.returncode}; {log}')
|
|
||||||
|
|
||||||
def copy_certs(source, destination):
|
|
||||||
destination.mkdir(parents=True, exist_ok=True)
|
|
||||||
files = list(source.glob('*'))
|
|
||||||
if not files:
|
|
||||||
raise RuntimeError('Missing public certificates: ' + str(source))
|
|
||||||
for path in files:
|
|
||||||
shutil.copy2(path, destination / path.name)
|
|
||||||
|
|
||||||
try:
|
|
||||||
for pair in range(1, 4):
|
|
||||||
for version, harness in [('baseline', args.baseline_harness.resolve()), ('candidate', args.candidate_harness.resolve())]:
|
|
||||||
leg = output / f'pair-{pair}-{version}'
|
|
||||||
leg.mkdir()
|
|
||||||
client, server = leg / 'client', leg / 'server'
|
|
||||||
client.mkdir()
|
|
||||||
server.mkdir()
|
|
||||||
run(['dotnet', harness, 'prepare', client], leg / 'prepare.log')
|
|
||||||
copy_certs(client / 'client-pki/own/certs', server / 'server-pki/trusted/certs')
|
|
||||||
server_log = (leg / 'server.log').open('w')
|
|
||||||
command = ['dotnet', str(harness), 'server', str(server)]
|
|
||||||
commands.append({'argv': command, 'log': str(leg / 'server.log')})
|
|
||||||
process = subprocess.Popen(command, env=env, stdout=server_log, stderr=subprocess.STDOUT)
|
|
||||||
try:
|
|
||||||
deadline = time.monotonic() + 60
|
|
||||||
while not (server / 'ready.json').exists():
|
|
||||||
if process.poll() is not None or time.monotonic() > deadline:
|
|
||||||
raise RuntimeError('Server failed to start: ' + str(leg / 'server.log'))
|
|
||||||
time.sleep(.1)
|
|
||||||
ready = json.loads((server / 'ready.json').read_text())
|
|
||||||
copy_certs(server / 'server-pki/own/certs', client / 'client-pki/trusted/certs')
|
|
||||||
run(['dotnet', harness, 'performance', client, ready['endpoint']], leg / 'client.log')
|
|
||||||
result = json.loads((client / 'result.json').read_text())
|
|
||||||
result.update({'pair': pair, 'version': version, 'server': ready})
|
|
||||||
finally:
|
|
||||||
(server / 'stop').touch()
|
|
||||||
try:
|
|
||||||
process.wait(timeout=15)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.terminate()
|
|
||||||
process.wait(timeout=10)
|
|
||||||
server_log.close()
|
|
||||||
result['serverResources'] = json.loads((server / 'server-resources.json').read_text())
|
|
||||||
results.append(result)
|
|
||||||
write(leg / 'measurement.json', result)
|
|
||||||
summary = []
|
|
||||||
for operation in ('scalar-read', 'scalar-write', 'array-read', 'array-write'):
|
|
||||||
samples = {}
|
|
||||||
for version in ('baseline', 'candidate'):
|
|
||||||
rounds = [o for r in results if r['version'] == version for o in r['observations'] if 'benchmarkRound' in o]
|
|
||||||
samples[version] = {'p95Milliseconds': [r['latencyMilliseconds'][operation]['p95'] for r in rounds],
|
|
||||||
'allocatedBytesPerOperation': [r['clientAllocatedBytesPerOperation'] for r in rounds],
|
|
||||||
'clientWorkingSetBytes': [r['clientWorkingSetBytes'] for r in rounds],
|
|
||||||
'serverWorkingSetBytes': [r['serverResources']['WorkingSet64'] for r in results if r['version'] == version]}
|
|
||||||
ratio = statistics.median(samples['candidate']['p95Milliseconds']) / statistics.median(samples['baseline']['p95Milliseconds'])
|
|
||||||
summary.append({'operation': operation, 'samples': samples, 'medianP95ChangePercent': 100 * (ratio - 1), 'status': 'diagnostic-regression' if ratio > 1.15 else 'diagnostic-within-budget'})
|
|
||||||
metadata['summary'] = summary
|
|
||||||
metadata['status'] = 'diagnostic-regression' if any(r['status'] == 'diagnostic-regression' for r in summary) else 'diagnostic-within-budget'
|
|
||||||
for row in summary:
|
|
||||||
print(f"{row['operation']}: median p95 {row['medianP95ChangePercent']:+.1f}% ({row['status']})")
|
|
||||||
finally:
|
|
||||||
for harness in (args.baseline_harness.resolve(), args.candidate_harness.resolve()):
|
|
||||||
for path in [harness, *harness.parent.glob('Opc.Ua*.dll'), *harness.parent.glob('ZB.MOM.WW.OtOpcUa*.dll'), *harness.parent.glob('OtOpcUa.Host.dll')]:
|
|
||||||
hashes.append({'path': str(path), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'bytes': path.stat().st_size})
|
|
||||||
write(output / 'artifact-hashes.json', hashes)
|
|
||||||
write(output / 'commands.json', commands)
|
|
||||||
write(output / 'measurements.json', results)
|
|
||||||
write(output / 'summary.json', metadata)
|
|
||||||
return 2 if metadata.get('status') == 'diagnostic-regression' else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Separate-process production application compatibility gate (no mixed SDK process)."""
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import secrets
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
|
||||||
HOST = 'src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj'
|
|
||||||
CLIENT = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj'
|
|
||||||
DRIVER = 'src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj'
|
|
||||||
CLI = 'src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj'
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(path, value):
|
|
||||||
path.write_text(json.dumps(value, indent=2) + '\n')
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument('--baseline-root', required=True, type=Path)
|
|
||||||
parser.add_argument('--baseline-artifacts', required=True, type=Path, help='Existing immutable host/ and client/ publishes')
|
|
||||||
parser.add_argument('--candidate-root', required=True, type=Path, help='Dedicated candidate checkout; this script builds it')
|
|
||||||
parser.add_argument('--container-image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble', help='Disposable local driver/CLI isolation; must supply .NET 10 and bash')
|
|
||||||
parser.add_argument('--output', required=True, type=Path, help='Fresh nonexistent evidence directory')
|
|
||||||
args = parser.parse_args()
|
|
||||||
args.baseline_root = args.baseline_root.resolve()
|
|
||||||
args.candidate_root = args.candidate_root.resolve()
|
|
||||||
args.baseline_artifacts = args.baseline_artifacts.resolve()
|
|
||||||
args.output = args.output.resolve()
|
|
||||||
args.output.mkdir(parents=True, exist_ok=False)
|
|
||||||
source_info = {}
|
|
||||||
for name, root in [('baseline', args.baseline_root), ('candidate', args.candidate_root)]:
|
|
||||||
source_info[name] = {'root': str(root), 'commit': subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip(),
|
|
||||||
'sdkPackages': str(root / 'Directory.Packages.props')}
|
|
||||||
source_info['scope'] = 'Loopback application contract and 3 warmed 50-operation rounds per scalar/Int32[3] read/write kind; shared development host, no full load/reconnect/failover/backend/soak performance claim.'
|
|
||||||
source_info['dateUtc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
||||||
write_json(args.output / 'source-info.json', source_info)
|
|
||||||
commands = []
|
|
||||||
matrix = []
|
|
||||||
env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_matrix_' + secrets.token_urlsafe(32))
|
|
||||||
env.pop('OPCUA_MATRIX_CLIENT_PKI', None)
|
|
||||||
env.pop('OPCUA_MATRIX_SERVER_SECURITY', None)
|
|
||||||
|
|
||||||
def run(command, log, process_env=env, timeout=300):
|
|
||||||
commands.append({'argv': ['<ephemeral-key>' if x == env['OPCUA_MATRIX_PASSWORD'] else str(x) for x in command], 'log': str(log)})
|
|
||||||
with log.open('w') as stream:
|
|
||||||
result = subprocess.run([str(x) for x in command], stdout=stream, stderr=subprocess.STDOUT, env=process_env, timeout=timeout)
|
|
||||||
return result.returncode
|
|
||||||
|
|
||||||
def build(version):
|
|
||||||
folder = args.output / ('build-' + version)
|
|
||||||
folder.mkdir()
|
|
||||||
project = ET.Element('Project', Sdk='Microsoft.NET.Sdk')
|
|
||||||
props = ET.SubElement(project, 'PropertyGroup')
|
|
||||||
for k, v in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'Nullable': 'enable', 'ImplicitUsings': 'enable', 'LangVersion': 'latest', 'DefineConstants': 'SDK2' if version == 'candidate' else ''}.items():
|
|
||||||
ET.SubElement(props, k).text = v
|
|
||||||
items = ET.SubElement(project, 'ItemGroup')
|
|
||||||
ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App')
|
|
||||||
if version == 'candidate':
|
|
||||||
for path in (HOST, CLIENT, DRIVER):
|
|
||||||
ET.SubElement(items, 'ProjectReference', Include=str(args.candidate_root / path))
|
|
||||||
else:
|
|
||||||
dlls = {}
|
|
||||||
for path in ('host', 'client'):
|
|
||||||
for dll in (args.baseline_artifacts / path).glob('*.dll'):
|
|
||||||
dlls[dll.name] = dll
|
|
||||||
for name, dll in sorted(dlls.items()):
|
|
||||||
ref = ET.SubElement(items, 'Reference', Include=name[:-4])
|
|
||||||
ET.SubElement(ref, 'HintPath').text = str(dll)
|
|
||||||
ET.SubElement(ref, 'Private').text = 'true'
|
|
||||||
ET.SubElement(items, 'Compile', Include=str(HERE / 'ProcessHost.cs'))
|
|
||||||
csproj = folder / 'ProcessHost.csproj'
|
|
||||||
ET.ElementTree(project).write(csproj, encoding='unicode')
|
|
||||||
status = run(['dotnet', 'build', csproj, '-c', 'Release', '-m:1', '--nologo'], folder / 'build.log')
|
|
||||||
if status:
|
|
||||||
raise RuntimeError(f'{version} harness build failed ({status}); see {folder / "build.log"}')
|
|
||||||
return folder / 'bin/Release/net10.0/ProcessHost.dll'
|
|
||||||
|
|
||||||
def certificates(source, target):
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
files = list(source.glob('*'))
|
|
||||||
if not files:
|
|
||||||
raise RuntimeError('No public certificates at ' + str(source))
|
|
||||||
for file in files:
|
|
||||||
shutil.copy2(file, target / file.name)
|
|
||||||
|
|
||||||
def receipt(paths):
|
|
||||||
return [{'path': str(p), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'bytes': p.stat().st_size} for p in sorted(set(paths)) if p.is_file()]
|
|
||||||
|
|
||||||
baseline = candidate = candidate_cli = None
|
|
||||||
try:
|
|
||||||
# Baseline builds only the portable harness against the exact published production DLLs.
|
|
||||||
baseline = build('baseline')
|
|
||||||
candidate = build('candidate')
|
|
||||||
candidate_cli = args.output / 'candidate-cli'
|
|
||||||
status = run(['dotnet', 'publish', args.candidate_root / CLI, '-c', 'Release', '-o', candidate_cli, '-m:1', '--nologo'], args.output / 'candidate-cli-build.log')
|
|
||||||
if status:
|
|
||||||
raise RuntimeError('Candidate CLI publish failed')
|
|
||||||
for name, client, server in [('baseline-client-to-baseline-server', baseline, baseline), ('baseline-client-to-candidate-server', baseline, candidate), ('candidate-client-to-baseline-server', candidate, baseline), ('candidate-client-to-candidate-server', candidate, candidate)]:
|
|
||||||
leg = args.output / name
|
|
||||||
leg.mkdir()
|
|
||||||
server_root = leg / 'server'
|
|
||||||
client_root = leg / 'shared'
|
|
||||||
server_root.mkdir()
|
|
||||||
client_root.mkdir()
|
|
||||||
status = run(['dotnet', client, 'prepare', client_root], leg / 'prepare.log')
|
|
||||||
if status:
|
|
||||||
matrix.append({'name': name, 'status': 'failed', 'stage': 'client-certificate-preparation'})
|
|
||||||
continue
|
|
||||||
certificates(client_root / 'client-pki/own/certs', server_root / 'server-pki/trusted/certs')
|
|
||||||
server_log = (leg / 'server.log').open('w')
|
|
||||||
command = ['dotnet', str(server), 'server', str(server_root)]
|
|
||||||
commands.append({'argv': command, 'log': str(leg / 'server.log')})
|
|
||||||
proc = subprocess.Popen(command, stdout=server_log, stderr=subprocess.STDOUT, env=env)
|
|
||||||
try:
|
|
||||||
deadline = time.monotonic() + 60
|
|
||||||
while not (server_root / 'ready.json').exists():
|
|
||||||
if proc.poll() is not None or time.monotonic() > deadline:
|
|
||||||
raise RuntimeError(f'{name}: server did not become ready; see server.log')
|
|
||||||
time.sleep(.1)
|
|
||||||
ready = json.loads((server_root / 'ready.json').read_text())
|
|
||||||
certificates(server_root / 'server-pki/own/certs', client_root / 'client-pki/trusted/certs')
|
|
||||||
status = run(['dotnet', client, 'client', client_root, ready['endpoint']], leg / 'shared.log')
|
|
||||||
result = json.loads((client_root / 'result.json').read_text())
|
|
||||||
history = [json.loads(x) for x in (server_root / 'history-requests.jsonl').read_text().splitlines()] if (server_root / 'history-requests.jsonl').exists() else []
|
|
||||||
result.update({'name': name, 'server': ready, 'status': 'passed' if status == 0 else 'failed', 'historyBackendRequests': history, 'certificateValidation': 'explicit trusted peers; server and Shared client autoaccept disabled', 'authentication': 'ephemeral API-key-shaped credential, exact fixture authenticator; real authority/ACL/method gates'})
|
|
||||||
write_json(leg / 'leg.json', result)
|
|
||||||
matrix.append(result)
|
|
||||||
except Exception as exc:
|
|
||||||
matrix.append({'name': name, 'status': 'failed', 'error': str(exc)})
|
|
||||||
finally:
|
|
||||||
(server_root / 'stop').touch()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=15)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=10)
|
|
||||||
server_log.close()
|
|
||||||
image_info = subprocess.check_output(['docker', 'image', 'inspect', args.container_image, '--format', '{{json .}}'], text=True)
|
|
||||||
image_data = json.loads(image_info)
|
|
||||||
write_json(args.output / 'container-image.json', {k: image_data.get(k) for k in ('Id', 'RepoDigests', 'Architecture', 'Os')})
|
|
||||||
for server_name, server in [('baseline', baseline), ('candidate', candidate)]:
|
|
||||||
name = 'candidate-driver-cli-to-' + server_name + '-server'
|
|
||||||
leg = args.output / name
|
|
||||||
leg.mkdir()
|
|
||||||
shutil.copy2(HERE / 'container-leg.sh', leg / 'run.sh')
|
|
||||||
container_name = 'otopcua-sdk2-matrix-' + uuid.uuid4().hex[:12]
|
|
||||||
command = ['docker', 'run', '--rm', '--name', container_name, '--network', 'none', '-e', 'OPCUA_MATRIX_PASSWORD',
|
|
||||||
'-v', str(candidate.parent) + ':/candidate:ro', '-v', str(server.parent) + ':/server:ro',
|
|
||||||
'-v', str(candidate_cli) + ':/cli:ro', '-v', str(leg) + ':/task', args.container_image, 'bash', '/task/run.sh']
|
|
||||||
try:
|
|
||||||
status = run(command, leg / 'container.log')
|
|
||||||
finally:
|
|
||||||
subprocess.run(['docker', 'rm', '-f', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
if (leg / 'driver/result.json').exists():
|
|
||||||
result = json.loads((leg / 'driver/result.json').read_text())
|
|
||||||
else:
|
|
||||||
result = {'cases': [], 'error': 'Driver result missing; inspect preparation/container/server logs'}
|
|
||||||
for operation, expected in [('read', 'Value: 92'), ('raw-history', '25 values returned.'), ('event-history', '25 events returned.')]:
|
|
||||||
log = leg / ('cli-' + operation + '.log')
|
|
||||||
passed = log.exists() and expected in log.read_text()
|
|
||||||
result['cases'].append({'name': 'production-CLI-' + operation, 'status': 'passed' if passed else 'failed', 'log': str(log), 'expectedOutput': expected})
|
|
||||||
result.update({'name': name, 'status': 'passed' if status == 0 and all(x['status'] == 'passed' for x in result['cases']) else 'failed',
|
|
||||||
'container': container_name, 'network': 'none; all peers in task container, separate SDK processes',
|
|
||||||
'driverCertificateValidation': 'explicit trusted peers; autoaccept disabled', 'cliCertificateValidation': 'explicit exchanged peers; CLI always configures AutoAcceptCertificates=true (CommandBase.CreateConnectionSettings)',
|
|
||||||
'server': json.loads((leg / 'server/ready.json').read_text()) if (leg / 'server/ready.json').exists() else None})
|
|
||||||
write_json(leg / 'leg.json', result)
|
|
||||||
matrix.append(result)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
paths = [p for p in (baseline, candidate, args.baseline_root / 'Directory.Packages.props', args.candidate_root / 'Directory.Packages.props', HERE / 'ProcessHost.cs', HERE / 'run.py', HERE / 'container-leg.sh') if p is not None]
|
|
||||||
for folder in (args.baseline_artifacts / 'host', args.baseline_artifacts / 'client', candidate.parent if candidate else None, candidate_cli):
|
|
||||||
if folder is not None:
|
|
||||||
paths.extend(folder.glob('Opc.Ua*.dll'))
|
|
||||||
paths.extend(folder.glob('*.deps.json'))
|
|
||||||
paths.extend(folder.glob('ZB.MOM.WW.OtOpcUa*.dll'))
|
|
||||||
paths.extend(folder.glob('OtOpcUa.Host.dll'))
|
|
||||||
paths.extend(folder.glob('*.runtimeconfig.json'))
|
|
||||||
write_json(args.output / 'artifact-hashes.json', receipt(paths))
|
|
||||||
write_json(args.output / 'commands.json', commands)
|
|
||||||
write_json(args.output / 'matrix.json', matrix)
|
|
||||||
return 1 if not matrix or any(x['status'] != 'passed' for x in matrix) else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
raise SystemExit(main())
|
|
||||||
+1
-3
@@ -71,9 +71,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
|
|||||||
ApplicationType = ApplicationType.Client,
|
ApplicationType = ApplicationType.Client,
|
||||||
ApplicationConfiguration = config
|
ApplicationConfiguration = config
|
||||||
};
|
};
|
||||||
// Key size is configured separately; this argument is a lifetime in months.
|
await app.CheckApplicationInstanceCertificatesAsync(false, lifeTimeInMonths: 2048, ct: ct);
|
||||||
// Keep the SDK creation default and reuse any valid certificate already in the store.
|
|
||||||
await app.CheckApplicationInstanceCertificatesAsync(false, ct: ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1533,6 +1533,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
alarm.ShelvingState = new ShelvedStateMachineState(alarm);
|
alarm.ShelvingState = new ShelvedStateMachineState(alarm);
|
||||||
alarm.MaxTimeShelved = new PropertyState<double>.Implementation<VariantBuilder>(alarm);
|
alarm.MaxTimeShelved = new PropertyState<double>.Implementation<VariantBuilder>(alarm);
|
||||||
alarm.SymbolicName = displayName;
|
alarm.SymbolicName = displayName;
|
||||||
|
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
|
||||||
|
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
||||||
|
|
||||||
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
|
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
|
||||||
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
|
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
|
||||||
// type's embedded definition; we do not hand-build them.
|
// type's embedded definition; we do not hand-build them.
|
||||||
@@ -1542,10 +1545,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
new QualifiedName(displayName, ns),
|
new QualifiedName(displayName, ns),
|
||||||
new LocalizedText(displayName),
|
new LocalizedText(displayName),
|
||||||
assignNodeIds: true);
|
assignNodeIds: true);
|
||||||
// Create initializes the embedded definition and resets ReferenceTypeId in SDK 2.0.1.
|
|
||||||
// Restore the application's parent ownership before AddChild: hierarchical folder browses
|
|
||||||
// must expose the alarm as a HasComponent object, as they did before the SDK upgrade.
|
|
||||||
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
|
|
||||||
|
|
||||||
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
|
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
|
||||||
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
|
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
|
||||||
|
|||||||
-58
@@ -1,7 +1,4 @@
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Security.Cryptography.X509Certificates;
|
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
|
||||||
@@ -12,61 +9,6 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters;
|
|||||||
/// <summary>Uses the real configuration factory so fake sessions cannot conceal SDK initialization failures.</summary>
|
/// <summary>Uses the real configuration factory so fake sessions cannot conceal SDK initialization failures.</summary>
|
||||||
public sealed class DefaultApplicationConfigurationFactoryTests
|
public sealed class DefaultApplicationConfigurationFactoryTests
|
||||||
{
|
{
|
||||||
[Theory]
|
|
||||||
[InlineData(false)]
|
|
||||||
[InlineData(true)]
|
|
||||||
public async Task New_certificates_use_the_default_lifetime_and_existing_private_keys_are_reused(bool seedLongerCertificate)
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
var root = Path.Combine(Path.GetTempPath(), $"otopcua-certificate-lifetime-{Guid.NewGuid():N}");
|
|
||||||
var settings = new ConnectionSettings
|
|
||||||
{
|
|
||||||
EndpointUrl = "opc.tcp://localhost:4840", CertificateStorePath = root,
|
|
||||||
SecurityMode = SecurityMode.None, AutoAcceptCertificates = false,
|
|
||||||
};
|
|
||||||
var factory = new DefaultApplicationConfigurationFactory();
|
|
||||||
ApplicationConfiguration? current = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (seedLongerCertificate)
|
|
||||||
{
|
|
||||||
var seed = await factory.CreateAsync(settings, ct);
|
|
||||||
await using var application = new ApplicationInstance(DefaultTelemetry.Create(_ => { })) { ApplicationConfiguration = seed };
|
|
||||||
(await application.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 60, ct)).ShouldBeTrue();
|
|
||||||
}
|
|
||||||
settings.SecurityMode = SecurityMode.SignAndEncrypt;
|
|
||||||
current = await factory.CreateAsync(settings, ct);
|
|
||||||
var certPath = Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").ShouldHaveSingleItem();
|
|
||||||
var privatePath = Directory.GetFiles(Path.Combine(root, "own", "private"), "*.pfx").ShouldHaveSingleItem();
|
|
||||||
using var certificate = X509CertificateLoader.LoadCertificateFromFile(certPath);
|
|
||||||
using var privateCertificate = X509CertificateLoader.LoadPkcs12FromFile(privatePath, null, OperatingSystem.IsMacOS() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet);
|
|
||||||
privateCertificate.HasPrivateKey.ShouldBeTrue();
|
|
||||||
certificate.Thumbprint.ShouldBe(privateCertificate.Thumbprint);
|
|
||||||
using var rsa = certificate.GetRSAPublicKey();
|
|
||||||
rsa.ShouldNotBeNull().KeySize.ShouldBeGreaterThanOrEqualTo(2048);
|
|
||||||
if (seedLongerCertificate)
|
|
||||||
certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddYears(4));
|
|
||||||
else
|
|
||||||
{
|
|
||||||
certificate.NotAfter.ToUniversalTime().ShouldBeGreaterThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime - 1));
|
|
||||||
certificate.NotAfter.ToUniversalTime().ShouldBeLessThan(DateTime.UtcNow.AddMonths(CertificateFactory.DefaultLifeTime).AddDays(2));
|
|
||||||
}
|
|
||||||
var originalKeyHash = SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct));
|
|
||||||
(current.CertificateManager as IDisposable)?.Dispose(); current = null;
|
|
||||||
current = await factory.CreateAsync(settings, ct);
|
|
||||||
using var reused = X509CertificateLoader.LoadCertificateFromFile(certPath);
|
|
||||||
reused.Thumbprint.ShouldBe(certificate.Thumbprint);
|
|
||||||
SHA256.HashData(await File.ReadAllBytesAsync(privatePath, ct)).ShouldBe(originalKeyHash,
|
|
||||||
"reinitializing with a shorter creation default must not rotate an existing certificate or private key");
|
|
||||||
Directory.GetFiles(Path.Combine(root, "own", "certs"), "*.der").Length.ShouldBe(1);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
(current?.CertificateManager as IDisposable)?.Dispose();
|
|
||||||
if (Directory.Exists(root)) Directory.Delete(root, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(SecurityMode.None)]
|
[InlineData(SecurityMode.None)]
|
||||||
[InlineData(SecurityMode.SignAndEncrypt)]
|
[InlineData(SecurityMode.SignAndEncrypt)]
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ public sealed class StatusCodeParityTests
|
|||||||
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
|
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
|
||||||
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
|
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
|
||||||
typeof(Opc.Ua.StatusCodes)
|
typeof(Opc.Ua.StatusCodes)
|
||||||
.GetProperties(BindingFlags.Public | BindingFlags.Static)
|
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||||
.Where(p => p.PropertyType == typeof(Opc.Ua.StatusCode))
|
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
|
||||||
.ToDictionary(p => p.Name, p => ((Opc.Ua.StatusCode)p.GetValue(null)!).Code, StringComparer.Ordinal);
|
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
|
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public abstract class AtTimeConformance : ConformanceBase
|
|||||||
var code = new Opc.Ua.StatusCode(GoodInterpolated);
|
var code = new Opc.Ua.StatusCode(GoodInterpolated);
|
||||||
code.AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
|
code.AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
|
||||||
Opc.Ua.StatusCode.IsGood(code).ShouldBeTrue();
|
Opc.Ua.StatusCode.IsGood(code).ShouldBeTrue();
|
||||||
new Opc.Ua.StatusCode(UncertainInterpolated).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal.Code);
|
new Opc.Ua.StatusCode(UncertainInterpolated).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ public abstract class ProcessedConformance : ConformanceBase
|
|||||||
{
|
{
|
||||||
new Opc.Ua.StatusCode(GoodCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
new Opc.Ua.StatusCode(GoodCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
||||||
new Opc.Ua.StatusCode(UncertainCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
new Opc.Ua.StatusCode(UncertainCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
||||||
new Opc.Ua.StatusCode(UncertainCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal.Code);
|
new Opc.Ua.StatusCode(UncertainCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
|
||||||
new Opc.Ua.StatusCode(NoDataCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadNoData.Code);
|
new Opc.Ua.StatusCode(NoDataCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadNoData);
|
||||||
new Opc.Ua.StatusCode(NoDataCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
new Opc.Ua.StatusCode(NoDataCalc).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Calculated);
|
||||||
new Opc.Ua.StatusCode(InvalidInputsCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadAggregateInvalidInputs.Code);
|
new Opc.Ua.StatusCode(InvalidInputsCalc).CodeBits.ShouldBe(Opc.Ua.StatusCodes.BadAggregateInvalidInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Drives the application's versioned 1.5.378.182 Part 13 compatibility calculators — the same factories
|
/// Drives the pinned OPC UA SDK's own Part 13 aggregate calculators (<c>Opc.Ua.Server</c> — the classes the SDK's
|
||||||
/// the upgraded server registers for live aggregates in-process over a sample set, so our processed reads can be compared with the
|
/// <c>AggregateManager</c> registers) in-process over a sample set, so our processed reads can be compared with the
|
||||||
/// reference implementation bucket by bucket (#677). The calculators run under the server's default
|
/// reference implementation bucket by bucket (#677). The calculators run under the server's default
|
||||||
/// <see cref="AggregateConfiguration"/> (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
|
/// <see cref="AggregateConfiguration"/> (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
|
||||||
/// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
|
/// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
|
||||||
@@ -18,7 +17,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class SdkAggregateOracle
|
public static class SdkAggregateOracle
|
||||||
{
|
{
|
||||||
private static readonly ITelemetryContext Telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
/// <summary>One processed value: value (double / int / null), full status code, timestamp.</summary>
|
/// <summary>One processed value: value (double / int / null), full status code, timestamp.</summary>
|
||||||
public readonly record struct Result(object? Value, uint StatusCode, DateTime Timestamp);
|
public readonly record struct Result(object? Value, uint StatusCode, DateTime Timestamp);
|
||||||
|
|
||||||
@@ -45,7 +43,22 @@ public static class SdkAggregateOracle
|
|||||||
var id = AggregateId(aggregate);
|
var id = AggregateId(aggregate);
|
||||||
var ms = interval.TotalMilliseconds;
|
var ms = interval.TotalMilliseconds;
|
||||||
var cfg = DefaultConfiguration;
|
var cfg = DefaultConfiguration;
|
||||||
IAggregateCalculator calc = CompatibilityAggregateFactory.Create(id, start, end, ms, false, cfg, Telemetry);
|
IAggregateCalculator calc = aggregate switch
|
||||||
|
{
|
||||||
|
HistoryAggregateType.Average or HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2
|
||||||
|
or HistoryAggregateType.Total or HistoryAggregateType.Total2 =>
|
||||||
|
new AverageAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||||
|
HistoryAggregateType.Minimum or HistoryAggregateType.Maximum or HistoryAggregateType.Range
|
||||||
|
or HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime =>
|
||||||
|
new MinMaxAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||||
|
HistoryAggregateType.Count => new CountAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||||
|
HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad =>
|
||||||
|
new StatusAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||||
|
HistoryAggregateType.Start or HistoryAggregateType.End or HistoryAggregateType.Delta =>
|
||||||
|
new StartEndAggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||||
|
HistoryAggregateType.Interpolative => new AggregateCalculator(id, start, end, ms, false, cfg, null),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "no SDK calculator mapped"),
|
||||||
|
};
|
||||||
|
|
||||||
// The SDK expects values in the direction of the read: ascending forward, descending reverse.
|
// The SDK expects values in the direction of the read: ascending forward, descending reverse.
|
||||||
var ordered = samples.OrderBy(s => s.Time).ToList();
|
var ordered = samples.OrderBy(s => s.Time).ToList();
|
||||||
@@ -55,8 +68,9 @@ public static class SdkAggregateOracle
|
|||||||
var results = new List<Result>();
|
var results = new List<Result>();
|
||||||
for (var guard = 0; guard < 100_000; guard++)
|
for (var guard = 0; guard < 100_000; guard++)
|
||||||
{
|
{
|
||||||
if (!calc.TryGetProcessedValue(true, out var dv)) break;
|
var dv = calc.GetProcessedValue(true);
|
||||||
results.Add(new Result(dv.WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy), dv.StatusCode.Code, (DateTime)dv.SourceTimestamp));
|
if (dv is null) break;
|
||||||
|
results.Add(new Result(dv.Value, dv.StatusCode.Code, DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc)));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -109,16 +123,20 @@ public static class SdkAggregateOracle
|
|||||||
problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems));
|
problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new(
|
private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new()
|
||||||
v.Kind switch
|
{
|
||||||
|
WrappedValue = v.Kind switch
|
||||||
{
|
{
|
||||||
HistorianValueKind.Empty => Variant.Null,
|
HistorianValueKind.Empty => Variant.Null,
|
||||||
HistorianValueKind.Int64 => Variant.From(v.Integer),
|
HistorianValueKind.Int64 => new Variant(v.Integer),
|
||||||
HistorianValueKind.Boolean => Variant.From(v.Number != 0),
|
HistorianValueKind.Boolean => new Variant(v.Number != 0),
|
||||||
HistorianValueKind.String => v.Text is { } text ? Variant.From(text) : Variant.Null,
|
HistorianValueKind.String => new Variant(v.Text),
|
||||||
_ => Variant.From(v.Number),
|
_ => new Variant(v.Number),
|
||||||
},
|
},
|
||||||
status, t, t);
|
StatusCode = status,
|
||||||
|
SourceTimestamp = t,
|
||||||
|
ServerTimestamp = t,
|
||||||
|
};
|
||||||
|
|
||||||
private static NodeId AggregateId(HistoryAggregateType a) => a switch
|
private static NodeId AggregateId(HistoryAggregateType a) => a switch
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -114,7 +114,7 @@ public sealed class GatewayBoundingValueTests
|
|||||||
{
|
{
|
||||||
var bound = await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(-30), reverse: false, 65536, Now, Ct);
|
var bound = await HistoryBounds.StartBoundAsync(Source(Aveva()), "T", At(-30), reverse: false, 65536, Now, Ct);
|
||||||
|
|
||||||
bound!.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound.Code);
|
bound!.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
|
||||||
bound.SourceTimestampUtc.ShouldBe(At(-30));
|
bound.SourceTimestampUtc.ShouldBe(At(-30));
|
||||||
bound.Value.ShouldBeNull();
|
bound.Value.ShouldBeNull();
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ public sealed class GatewayBoundingValueTests
|
|||||||
after.Value.ShouldBe(5d);
|
after.Value.ShouldBe(5d);
|
||||||
|
|
||||||
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(101)), At(100), 65536, Now, Ct);
|
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(101)), At(100), 65536, Now, Ct);
|
||||||
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound.Code);
|
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
|
||||||
missing.SourceTimestampUtc.ShouldBe(At(101));
|
missing.SourceTimestampUtc.ShouldBe(At(101));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ public sealed class GatewayBoundingValueTests
|
|||||||
var calls = fake.ReadRawCallCount;
|
var calls = fake.ReadRawCallCount;
|
||||||
var beyond = Now.AddDays(3);
|
var beyond = Now.AddDays(3);
|
||||||
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct);
|
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct);
|
||||||
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound.Code);
|
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
|
||||||
missing.SourceTimestampUtc.ShouldBe(beyond);
|
missing.SourceTimestampUtc.ShouldBe(beyond);
|
||||||
fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read");
|
fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read");
|
||||||
source.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
source.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||||
|
|||||||
+19
-22
@@ -106,7 +106,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var first = ReadRaw(nm, nodeId, At(0), At(3600), 5000, null);
|
var first = ReadRaw(nm, nodeId, At(0), At(3600), 5000, null);
|
||||||
first.Error.StatusCode.ShouldBe(StatusCodes.Good);
|
first.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
first.Values.Count.ShouldBe(FirstPage);
|
first.Values.Count.ShouldBe(FirstPage);
|
||||||
first.Cp.ShouldNotBeNull();
|
first.Cp.ShouldNotBeNull();
|
||||||
// The budget the client asked for (+ lookahead + allowance, refused naming 1000), the ceiling re-read that cannot
|
// The budget the client asked for (+ lookahead + allowance, refused naming 1000), the ceiling re-read that cannot
|
||||||
@@ -114,7 +114,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([5000 + 1 + RawAggregateReader.PreWindowAllowance, Ceiling, Ceiling]);
|
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([5000 + 1 + RawAggregateReader.PreWindowAllowance, Ceiling, Ceiling]);
|
||||||
|
|
||||||
var (rest, _, status) = DrainFrom(nm, nodeId, At(0), At(3600), 5000, first.Cp);
|
var (rest, _, status) = DrainFrom(nm, nodeId, At(0), At(3600), 5000, first.Cp);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
first.Values.Concat(rest).ShouldBe(PerSecond(3600).Select(s => s.Item2));
|
first.Values.Concat(rest).ShouldBe(PerSecond(3600).Select(s => s.Item2));
|
||||||
Refusals(fake).ShouldBe(1);
|
Refusals(fake).ShouldBe(1);
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
? DrainFrom(nm, nodeId, At(3600), At(-1), n, null)
|
? DrainFrom(nm, nodeId, At(3600), At(-1), n, null)
|
||||||
: DrainFrom(nm, nodeId, At(0), At(3600), n, null);
|
: DrainFrom(nm, nodeId, At(0), At(3600), n, null);
|
||||||
|
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
var expected = stored.Select(s => s.Item2);
|
var expected = stored.Select(s => s.Item2);
|
||||||
values.ShouldBe(reverse ? expected.Reverse() : expected);
|
values.ShouldBe(reverse ? expected.Reverse() : expected);
|
||||||
pages[0].ShouldBe(FirstPage);
|
pages[0].ShouldBe(FirstPage);
|
||||||
@@ -217,10 +217,10 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
|
|
||||||
var (values, pages, status) = DrainFrom(nm, nodeId, At(0), At(5000), n, null, maxPages: 60);
|
var (values, pages, status) = DrainFrom(nm, nodeId, At(0), At(5000), n, null, maxPages: 60);
|
||||||
var expected = stored.Select(s => s.Item2).ToList();
|
var expected = stored.Select(s => s.Item2).ToList();
|
||||||
if (status == StatusCodes.Good) values.ShouldBe(expected);
|
if (status.Code == StatusCodes.Good) values.ShouldBe(expected);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
status.ShouldBe(StatusCodes.BadTooManyOperations);
|
status.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||||
values.ShouldBe(expected.Take(values.Count)); // an exact prefix
|
values.ShouldBe(expected.Take(values.Count)); // an exact prefix
|
||||||
size.ShouldBeGreaterThan(L - 2, "a cluster that fits the over-fetch is always served");
|
size.ShouldBeGreaterThan(L - 2, "a cluster that fits the over-fetch is always served");
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
ds.MaxRawValuesPerRead.ShouldBe(0);
|
ds.MaxRawValuesPerRead.ShouldBe(0);
|
||||||
|
|
||||||
var (rest, pages, status) = DrainFrom(nm, nodeId, At(0), At(5000), 0, first.Cp, maxPages: 20);
|
var (rest, pages, status) = DrainFrom(nm, nodeId, At(0), At(5000), 0, first.Cp, maxPages: 20);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
first.Values.Concat(rest).ShouldBe(stored.Select(s => s.Item2));
|
first.Values.Concat(rest).ShouldBe(stored.Select(s => s.Item2));
|
||||||
pages.Count.ShouldBeLessThan(20);
|
pages.Count.ShouldBeLessThan(20);
|
||||||
}
|
}
|
||||||
@@ -278,7 +278,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
fake.MaxValuesCeiling = 500; // the operator lowered it
|
fake.MaxValuesCeiling = 500; // the operator lowered it
|
||||||
fake.RawCalls.Clear();
|
fake.RawCalls.Clear();
|
||||||
var (rest, pages, status) = DrainFrom(nm, nodeId, At(0), At(3600), 5000, p2.Cp, maxPages: 20);
|
var (rest, pages, status) = DrainFrom(nm, nodeId, At(0), At(3600), 5000, p2.Cp, maxPages: 20);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
got.AddRange(rest);
|
got.AddRange(rest);
|
||||||
got.ShouldBe(stored.Select(s => s.Item2));
|
got.ShouldBe(stored.Select(s => s.Item2));
|
||||||
pages.ShouldAllBe(p => p <= 500 - RawAggregateReader.PreWindowAllowance);
|
pages.ShouldAllBe(p => p <= 500 - RawAggregateReader.PreWindowAllowance);
|
||||||
@@ -302,7 +302,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
var start = At(10).AddMilliseconds(500);
|
var start = At(10).AddMilliseconds(500);
|
||||||
var end = At(3000).AddMilliseconds(500);
|
var end = At(3000).AddMilliseconds(500);
|
||||||
var (values, pages, status) = DrainFrom(nm, nodeId, start, end, 5000, null, returnBounds: true);
|
var (values, pages, status) = DrainFrom(nm, nodeId, start, end, 5000, null, returnBounds: true);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
values.ShouldBe(Enumerable.Range(10, 2992).Select(i => (double)i));
|
values.ShouldBe(Enumerable.Range(10, 2992).Select(i => (double)i));
|
||||||
pages.ShouldAllBe(p => p <= L);
|
pages.ShouldAllBe(p => p <= L);
|
||||||
Refusals(fake).ShouldBe(0);
|
Refusals(fake).ShouldBe(0);
|
||||||
@@ -325,7 +325,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var r = ReadRaw(nm, nodeId, DateTime.MinValue, T0.AddMilliseconds(200_000), n, null);
|
var r = ReadRaw(nm, nodeId, DateTime.MinValue, T0.AddMilliseconds(200_000), n, null);
|
||||||
r.Error.StatusCode.ShouldBe(StatusCodes.Good);
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
r.Cp.ShouldNotBeNull();
|
r.Cp.ShouldNotBeNull();
|
||||||
var page = ceiling - RawAggregateReader.PreWindowAllowance - 1;
|
var page = ceiling - RawAggregateReader.PreWindowAllowance - 1;
|
||||||
r.Values.ShouldBe(Enumerable.Range(200_000 - r.Values.Count, r.Values.Count).Reverse().Select(i => (double)i));
|
r.Values.ShouldBe(Enumerable.Range(200_000 - r.Values.Count, r.Values.Count).Reverse().Select(i => (double)i));
|
||||||
@@ -360,7 +360,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (values, pages, status) = DrainFrom(nm, nodeId, DateTime.UnixEpoch, DateTime.UnixEpoch.AddDays(1), 0, null);
|
var (values, pages, status) = DrainFrom(nm, nodeId, DateTime.UnixEpoch, DateTime.UnixEpoch.AddDays(1), 0, null);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
values.ShouldBe(stored.Select(s => s.Item2));
|
values.ShouldBe(stored.Select(s => s.Item2));
|
||||||
pages.Count.ShouldBeGreaterThan(1);
|
pages.Count.ShouldBeGreaterThan(1);
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
{
|
{
|
||||||
ReadRaw(nm, nodeId, DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(5), 5000, null); // learns the ceiling
|
ReadRaw(nm, nodeId, DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(5), 5000, null); // learns the ceiling
|
||||||
var r = ReadRaw(nm, nodeId, DateTime.MinValue, DateTime.UnixEpoch.AddDays(30), 10, null);
|
var r = ReadRaw(nm, nodeId, DateTime.MinValue, DateTime.UnixEpoch.AddDays(30), 10, null);
|
||||||
r.Error.StatusCode.ShouldBe(StatusCodes.Good);
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
r.Values.ShouldBe(Enumerable.Range(2991, 10).Reverse().Select(i => (double)i)); // the LATEST ten, newest first
|
r.Values.ShouldBe(Enumerable.Range(2991, 10).Reverse().Select(i => (double)i)); // the LATEST ten, newest first
|
||||||
}
|
}
|
||||||
finally { await host.DisposeAsync(); }
|
finally { await host.DisposeAsync(); }
|
||||||
@@ -425,7 +425,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
fake.RawCalls.Clear();
|
fake.RawCalls.Clear();
|
||||||
|
|
||||||
var (values, _, status) = DrainFrom(nm, nodeId, At(0), x.AddDays(1), n, null);
|
var (values, _, status) = DrainFrom(nm, nodeId, At(0), x.AddDays(1), n, null);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
values.ShouldBe(stored.Select(s => s.Item2));
|
values.ShouldBe(stored.Select(s => s.Item2));
|
||||||
Refusals(fake).ShouldBe(0);
|
Refusals(fake).ShouldBe(0);
|
||||||
EveryRequestFits(fake);
|
EveryRequestFits(fake);
|
||||||
@@ -476,7 +476,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var r = ReadRaw(nm, nodeId, At(0), At(10), 10, null);
|
var r = ReadRaw(nm, nodeId, At(0), At(10), 10, null);
|
||||||
r.Error.StatusCode.ShouldBe(StatusCodes.BadNoCommunication);
|
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||||
}
|
}
|
||||||
finally { await host.DisposeAsync(); }
|
finally { await host.DisposeAsync(); }
|
||||||
}
|
}
|
||||||
@@ -528,7 +528,7 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
private static (IReadOnlyList<double> Values, ServiceResult Error, byte[]? Cp) ReadRaw(
|
private static (IReadOnlyList<double> Values, ServiceResult Error, byte[]? Cp) ReadRaw(
|
||||||
OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp, bool returnBounds = false)
|
OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp, bool returnBounds = false)
|
||||||
{
|
{
|
||||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
{
|
{
|
||||||
ReturnBounds = returnBounds,
|
ReturnBounds = returnBounds,
|
||||||
@@ -537,19 +537,16 @@ public sealed class GatewayCeilingPagingTests : IDisposable
|
|||||||
EndTime = end,
|
EndTime = end,
|
||||||
NumValuesPerNode = max,
|
NumValuesPerNode = max,
|
||||||
};
|
};
|
||||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = inboundCp is null ? default : ByteString.From(inboundCp) } };
|
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = inboundCp } };
|
||||||
var results = new List<SdkHistoryReadResult> { null! };
|
var results = new List<SdkHistoryReadResult> { null! };
|
||||||
var errors = new List<ServiceResult> { null! };
|
var errors = new List<ServiceResult> { null! };
|
||||||
nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||||
|
|
||||||
var values = new List<double>();
|
var values = new List<double>();
|
||||||
if (results[0]?.HistoryData is { IsNull: false } historyData)
|
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||||
{
|
foreach (var dv in ((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues)
|
||||||
var data = (HistoryData)ExtensionObject.ToEncodeable(historyData)!;
|
values.Add(Convert.ToDouble(dv.Value));
|
||||||
foreach (var dv in data.DataValues)
|
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||||
values.Add(Convert.ToDouble(SdkValueBoundary.ToClr(dv.WrappedValue)));
|
|
||||||
}
|
|
||||||
return (values, errors[0], results[0]?.ContinuationPoint is { IsNull: false } cp ? cp.ToArray() : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
+8
-11
@@ -205,7 +205,7 @@ public sealed class GatewayRawCapTests : IDisposable
|
|||||||
? DrainFrom(nm, nodeId, At(3600), At(-1), n, null)
|
? DrainFrom(nm, nodeId, At(3600), At(-1), n, null)
|
||||||
: DrainFrom(nm, nodeId, At(0), At(3600), n, null);
|
: DrainFrom(nm, nodeId, At(0), At(3600), n, null);
|
||||||
|
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
var expected = Enumerable.Range(0, 3600).Select(i => (double)i);
|
var expected = Enumerable.Range(0, 3600).Select(i => (double)i);
|
||||||
values.ShouldBe(reverse ? expected.Reverse() : expected);
|
values.ShouldBe(reverse ? expected.Reverse() : expected);
|
||||||
pages.Count.ShouldBeGreaterThan(1, "a year-sized window at N = 0 is paged, not one shot");
|
pages.Count.ShouldBeGreaterThan(1, "a year-sized window at N = 0 is paged, not one shot");
|
||||||
@@ -224,7 +224,7 @@ public sealed class GatewayRawCapTests : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (values, pages, status) = DrainFrom(nm, nodeId, At(10).AddMilliseconds(500), At(3000).AddMilliseconds(500), 0, null, returnBounds: true);
|
var (values, pages, status) = DrainFrom(nm, nodeId, At(10).AddMilliseconds(500), At(3000).AddMilliseconds(500), 0, null, returnBounds: true);
|
||||||
status.ShouldBe(StatusCodes.Good);
|
status.Code.ShouldBe(StatusCodes.Good);
|
||||||
values.ShouldBe(Enumerable.Range(10, 2992).Select(i => (double)i)); // start bound +10, data +11..+3000, end bound +3001
|
values.ShouldBe(Enumerable.Range(10, 2992).Select(i => (double)i)); // start bound +10, data +11..+3000, end bound +3001
|
||||||
pages.ShouldAllBe(p => p <= Cap);
|
pages.ShouldAllBe(p => p <= Cap);
|
||||||
fake.RawCalls.ShouldAllBe(c => c.MaxValues > 0 && c.MaxValues <= Cap + RawAggregateReader.PreWindowAllowance);
|
fake.RawCalls.ShouldAllBe(c => c.MaxValues > 0 && c.MaxValues <= Cap + RawAggregateReader.PreWindowAllowance);
|
||||||
@@ -277,24 +277,21 @@ public sealed class GatewayRawCapTests : IDisposable
|
|||||||
private static (IReadOnlyList<double> Values, ServiceResult Error, byte[]? Cp) ReadRaw(
|
private static (IReadOnlyList<double> Values, ServiceResult Error, byte[]? Cp) ReadRaw(
|
||||||
OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp, bool returnBounds = false)
|
OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp, bool returnBounds = false)
|
||||||
{
|
{
|
||||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||||
var details = new ReadRawModifiedDetails
|
var details = new ReadRawModifiedDetails
|
||||||
{
|
{
|
||||||
ReturnBounds = returnBounds, IsReadModified = false, StartTime = start, EndTime = end, NumValuesPerNode = max,
|
ReturnBounds = returnBounds, IsReadModified = false, StartTime = start, EndTime = end, NumValuesPerNode = max,
|
||||||
};
|
};
|
||||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = inboundCp is null ? default : ByteString.From(inboundCp) } };
|
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = inboundCp } };
|
||||||
var results = new List<SdkHistoryReadResult> { null! };
|
var results = new List<SdkHistoryReadResult> { null! };
|
||||||
var errors = new List<ServiceResult> { null! };
|
var errors = new List<ServiceResult> { null! };
|
||||||
nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||||
|
|
||||||
var values = new List<double>();
|
var values = new List<double>();
|
||||||
if (results[0]?.HistoryData is { IsNull: false } historyData)
|
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||||
{
|
foreach (var dv in ((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues)
|
||||||
var data = (HistoryData)ExtensionObject.ToEncodeable(historyData)!;
|
values.Add(Convert.ToDouble(dv.Value));
|
||||||
foreach (var dv in data.DataValues)
|
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||||
values.Add(Convert.ToDouble(SdkValueBoundary.ToClr(dv.WrappedValue)));
|
|
||||||
}
|
|
||||||
return (values, errors[0], results[0]?.ContinuationPoint is { IsNull: false } cp ? cp.ToArray() : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
-2
@@ -23,8 +23,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Test the same preserved aggregate algorithms registered by the application server. -->
|
|
||||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb\ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+4
-4
@@ -9,7 +9,7 @@ public sealed class StatusCodeOracleTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Calculated_bits_match_the_SDK()
|
public void Calculated_bits_match_the_SDK()
|
||||||
{
|
{
|
||||||
var sdk = Opc.Ua.StatusCodes.Good.WithAggregateBits(Opc.Ua.AggregateBits.Calculated);
|
var sdk = new Opc.Ua.StatusCode(Opc.Ua.StatusCodes.Good).SetAggregateBits(Opc.Ua.AggregateBits.Calculated);
|
||||||
sdk.Code.ShouldBe(HistorianInfoBits.WithCalculated(HistorianStatusCodes.Good));
|
sdk.Code.ShouldBe(HistorianInfoBits.WithCalculated(HistorianStatusCodes.Good));
|
||||||
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
|
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ public sealed class StatusCodeOracleTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Interpolated_bits_match_the_SDK()
|
public void Interpolated_bits_match_the_SDK()
|
||||||
{
|
{
|
||||||
var sdk = Opc.Ua.StatusCodes.Good.WithAggregateBits(Opc.Ua.AggregateBits.Interpolated);
|
var sdk = new Opc.Ua.StatusCode(Opc.Ua.StatusCodes.Good).SetAggregateBits(Opc.Ua.AggregateBits.Interpolated);
|
||||||
sdk.Code.ShouldBe(HistorianInfoBits.WithInterpolated(HistorianStatusCodes.Good));
|
sdk.Code.ShouldBe(HistorianInfoBits.WithInterpolated(HistorianStatusCodes.Good));
|
||||||
((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated);
|
((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated);
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ public sealed class StatusCodeOracleTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A stored status that already carries a historian bit (a sample written via HistoryUpdate with the Calculated
|
/// A stored status that already carries a historian bit (a sample written via HistoryUpdate with the Calculated
|
||||||
/// bit, say) must come back with ONE historian bit, never both: the existing pair is masked off first. The SDK
|
/// bit, say) must come back with ONE historian bit, never both: the existing pair is masked off first. The SDK
|
||||||
/// agrees — <c>WithAggregateBits</c> replaces, it does not OR.
|
/// agrees — <c>SetAggregateBits</c> replaces, it does not OR.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Setting_one_historian_bit_clears_the_other()
|
public void Setting_one_historian_bit_clears_the_other()
|
||||||
@@ -45,7 +45,7 @@ public sealed class StatusCodeOracleTests
|
|||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Generic_bad_matches_the_SDK() =>
|
public void Generic_bad_matches_the_SDK() =>
|
||||||
HistorianStatusCodes.Bad.ShouldBe(Opc.Ua.StatusCodes.Bad.Code);
|
HistorianStatusCodes.Bad.ShouldBe(Opc.Ua.StatusCodes.Bad);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Info_type_is_data_value() =>
|
public void Info_type_is_data_value() =>
|
||||||
|
|||||||
-2
@@ -28,8 +28,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Test the same preserved aggregate algorithms registered by the application server. -->
|
|
||||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
-2
@@ -22,8 +22,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Test the same preserved aggregate algorithms registered by the application server. -->
|
|
||||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.OpcUaServer\ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj"/>
|
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer\ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common\ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+1
-14
@@ -137,7 +137,6 @@ public sealed class AclServerObjectModelChangeTests
|
|||||||
rig.EnsureEquipment();
|
rig.EnsureEquipment();
|
||||||
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||||
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw);
|
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw);
|
||||||
rig.Sink.WriteValue(RawVariable, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
||||||
rig.Nm.NodeWriteGateway = new RejectingGateway();
|
rig.Nm.NodeWriteGateway = new RejectingGateway();
|
||||||
|
|
||||||
var (granted, grantedEvents, grantedItem, grantedSubscription) =
|
var (granted, grantedEvents, grantedItem, grantedSubscription) =
|
||||||
@@ -146,15 +145,6 @@ public sealed class AclServerObjectModelChangeTests
|
|||||||
await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true);
|
await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true);
|
||||||
|
|
||||||
var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||||
var serverSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single(s =>
|
|
||||||
s.Identity is ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.RoleCarryingUserIdentity { ApiKeyId: KeyId });
|
|
||||||
using var permissionContext = new Opc.Ua.Server.OperationContext(new RequestHeader(), null!,
|
|
||||||
Opc.Ua.Server.RequestType.Read, RequestLifetime.None, serverSession);
|
|
||||||
var typePermission = rig.Nm.ValidateRolePermissions(permissionContext, ObjectTypeIds.AuditWriteUpdateEventType, PermissionType.ReceiveEvents);
|
|
||||||
var sourcePermission = rig.Nm.ValidateRolePermissions(permissionContext, variable, PermissionType.ReceiveEvents);
|
|
||||||
typePermission.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied,
|
|
||||||
"ordinary SDK audit emission must stay denied; only the scoped application path may grant receive permission");
|
|
||||||
ServiceResult.IsGood(sourcePermission).ShouldBeTrue($"Audit source permission: {sourcePermission}");
|
|
||||||
var write = await granted.WriteAsync(
|
var write = await granted.WriteAsync(
|
||||||
null,
|
null,
|
||||||
[new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }],
|
[new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }],
|
||||||
@@ -166,11 +156,8 @@ public sealed class AclServerObjectModelChangeTests
|
|||||||
|
|
||||||
// Reported after the audit event through the same delivery, so it queues behind it on the item.
|
// Reported after the audit event through the same delivery, so it queues behind it on the item.
|
||||||
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
||||||
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA)
|
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA));
|
||||||
&& grantedEvents.Of(grantedItem).Any(NamesEqA));
|
|
||||||
|
|
||||||
grantedEvents.Of(grantedItem).Count(f => f[RecordedEvents.EventTypeField].GetNodeId() == ObjectTypeIds.AuditWriteUpdateEventType)
|
|
||||||
.ShouldBe(1, "one application audit is delivered without an automatic SDK duplicate");
|
|
||||||
narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse(
|
narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse(
|
||||||
"a session that may not read the node must not receive its written values through an audit event");
|
"a session that may not read the node must not receive its written values through an audit event");
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -144,7 +144,7 @@ public sealed class AdvertisedAggregateFunctionsWireTests
|
|||||||
rig.Enforce(false, [Key()]);
|
rig.Enforce(false, [Key()]);
|
||||||
var id = rig.EnsureRawVariable("agg", "Double", writable: false);
|
var id = rig.EnsureRawVariable("agg", "Double", writable: false);
|
||||||
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
||||||
var aggregateId = (NodeId)typeof(ObjectIds).GetProperty("AggregateFunction_" + aggregateName)!.GetValue(null)!;
|
var aggregateId = (NodeId)typeof(ObjectIds).GetField("AggregateFunction_" + aggregateName)!.GetValue(null)!;
|
||||||
|
|
||||||
(await AdvertisedAsync(session, ObjectIds.HistoryServerCapabilities_AggregateFunctions, ct))
|
(await AdvertisedAsync(session, ObjectIds.HistoryServerCapabilities_AggregateFunctions, ct))
|
||||||
.ShouldNotContain(aggregateName);
|
.ShouldNotContain(aggregateName);
|
||||||
@@ -164,7 +164,6 @@ public sealed class AdvertisedAggregateFunctionsWireTests
|
|||||||
{
|
{
|
||||||
AggregateType = aggregateId,
|
AggregateType = aggregateId,
|
||||||
ProcessingInterval = 1000,
|
ProcessingInterval = 1000,
|
||||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
|
||||||
StartTime = DateTime.UtcNow,
|
StartTime = DateTime.UtcNow,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+4146
-6151
File diff suppressed because one or more lines are too long
+12
-38
@@ -11,14 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|||||||
/// <summary>Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors.</summary>
|
/// <summary>Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors.</summary>
|
||||||
public sealed class Sdk2AggregateCompatibilityTests
|
public sealed class Sdk2AggregateCompatibilityTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData(false)]
|
public void All_37_factories_preserve_the_4144_baseline_vectors()
|
||||||
[InlineData(true)]
|
|
||||||
public async Task All_37_factories_preserve_the_6144_baseline_vectors(bool throughRuntimeManager)
|
|
||||||
{
|
{
|
||||||
await using var rig = throughRuntimeManager
|
|
||||||
? await ServerEventRig.StartAsync("AggregateBaseline", encrypted: false, TestContext.Current.CancellationToken)
|
|
||||||
: null;
|
|
||||||
var baseline = JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
var baseline = JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||||
var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc);
|
var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc);
|
||||||
@@ -27,32 +22,22 @@ public sealed class Sdk2AggregateCompatibilityTests
|
|||||||
{
|
{
|
||||||
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
|
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
|
||||||
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
|
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
|
||||||
var profile = vector.Configuration;
|
var config = new AggregateConfiguration { TreatUncertainAsBad = vector.UncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false };
|
||||||
var config = new AggregateConfiguration
|
var calculator = CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
||||||
{
|
var output = new List<Value>(); string? error = null;
|
||||||
TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad,
|
|
||||||
PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100,
|
|
||||||
UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false,
|
|
||||||
UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false,
|
|
||||||
};
|
|
||||||
var calculator = throughRuntimeManager
|
|
||||||
? rig!.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config)
|
|
||||||
: CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
|
||||||
calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}.");
|
|
||||||
var output = new List<Value>(); var accepted = new List<bool>(); string? error = null;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
|
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
|
||||||
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
||||||
{
|
{
|
||||||
var time = epoch.AddSeconds(sample.Seconds);
|
var time = epoch.AddSeconds(sample.Seconds);
|
||||||
accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds)));
|
calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time));
|
||||||
if (vector.Incremental) Drain(false);
|
if (vector.Incremental) Drain(false);
|
||||||
}
|
}
|
||||||
Drain(true);
|
Drain(true);
|
||||||
}
|
}
|
||||||
catch (Exception ex) { error = ex.GetType().FullName; }
|
catch (Exception ex) { error = ex.GetType().FullName; }
|
||||||
if (error != vector.Exception || !output.SequenceEqual(vector.Output) || (vector.QueueAccepted is { } expectedAccepted && !accepted.SequenceEqual(expectedAccepted)))
|
if (error != vector.Exception || !output.SequenceEqual(vector.Output))
|
||||||
{
|
{
|
||||||
failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}");
|
failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}");
|
||||||
}
|
}
|
||||||
@@ -62,31 +47,20 @@ public sealed class Sdk2AggregateCompatibilityTests
|
|||||||
{
|
{
|
||||||
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
|
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
|
||||||
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
|
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
|
||||||
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds));
|
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, ((DateTime)value.SourceTimestamp).Ticks, ((DateTime)value.ServerTimestamp).Ticks, value.SourcePicoseconds, value.ServerPicoseconds));
|
||||||
}
|
}
|
||||||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
baseline.Vectors.Length.ShouldBe(6144);
|
baseline.Vectors.Length.ShouldBe(4144);
|
||||||
baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37);
|
baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37);
|
||||||
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing.");
|
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// UA FILETIME zero is the old CLR unset sentinel, not a different historical instant.
|
static object? Payload(Sample sample) => sample.Type switch { "Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
|
||||||
private static long LegacyTimestampTicks(DateTimeUtc time) => time.IsNull ? DateTime.MinValue.Ticks : ((DateTime)time).Ticks;
|
|
||||||
static object? Payload(Sample sample) => sample.Type switch { "SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
|
||||||
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
|
|
||||||
static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() };
|
static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() };
|
||||||
record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary<string,Sample[]> Data, Vector[] Vectors);
|
record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary<string,Sample[]> Data, Vector[] Vectors);
|
||||||
record Sample(double Seconds,string Type,string? Payload,uint Status,ushort SourcePicoseconds = 0,ushort ServerPicoseconds = 0,long ServerOffsetTicks = 0);
|
record Sample(double Seconds,string Type,string? Payload,uint Status);
|
||||||
record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);
|
record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);
|
||||||
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception, Configuration? Configuration = null,bool[]? QueueAccepted = null);
|
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception);
|
||||||
record Configuration(bool TreatUncertainAsBad,byte PercentDataBad,byte PercentDataGood,bool UseSlopedExtrapolation,bool UseServerCapabilitiesDefaults);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
||||||
|
|
||||||
/// <summary>Preserves baseline alarm discovery through hierarchical folder browses in both application realms.</summary>
|
|
||||||
public sealed class Sdk2AlarmBrowseCompatibilityTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData("OffNormalAlarm", AddressSpaceRealm.Raw)]
|
|
||||||
[InlineData("DiscreteAlarm", AddressSpaceRealm.Raw)]
|
|
||||||
[InlineData("AlarmCondition", AddressSpaceRealm.Raw)]
|
|
||||||
[InlineData("OffNormalAlarm", AddressSpaceRealm.Uns)]
|
|
||||||
[InlineData("DiscreteAlarm", AddressSpaceRealm.Uns)]
|
|
||||||
[InlineData("AlarmCondition", AddressSpaceRealm.Uns)]
|
|
||||||
public async Task Alarm_is_a_hierarchical_component_with_the_same_inverse_parent_and_object_type(
|
|
||||||
string type, AddressSpaceRealm realm)
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await using var rig = await ServerEventRig.StartAsync("AlarmBrowseCompatibility", false, ct);
|
|
||||||
rig.Enforce(false, [ServerEventRig.Key()]);
|
|
||||||
rig.Sink.EnsureFolder("browse-parent", null, "Parent", realm);
|
|
||||||
rig.Sink.MaterialiseAlarmCondition("browse-alarm", "browse-parent", "Alarm", type, 500, realm,
|
|
||||||
isNative: realm == AddressSpaceRealm.Raw);
|
|
||||||
using var session = await rig.ConnectAsync(true, ct);
|
|
||||||
var uri = realm == AddressSpaceRealm.Raw ? V3NodeIds.RawNamespaceUri : V3NodeIds.UnsNamespaceUri;
|
|
||||||
var ns = session.NamespaceUris.GetIndex(uri);
|
|
||||||
ns.ShouldBeGreaterThan(0);
|
|
||||||
var parent = new NodeId("browse-parent", (ushort)ns);
|
|
||||||
var alarm = new NodeId("browse-alarm", (ushort)ns);
|
|
||||||
var expectedType = type switch
|
|
||||||
{
|
|
||||||
"OffNormalAlarm" => ObjectTypeIds.OffNormalAlarmType,
|
|
||||||
"DiscreteAlarm" => ObjectTypeIds.DiscreteAlarmType,
|
|
||||||
_ => ObjectTypeIds.AlarmConditionType,
|
|
||||||
};
|
|
||||||
|
|
||||||
var children = await BrowseAsync(session, parent, ReferenceTypeIds.HierarchicalReferences, BrowseDirection.Forward, true, ct);
|
|
||||||
var discovered = children.SingleOrDefault(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == alarm);
|
|
||||||
discovered.ShouldNotBeNull("the baseline folder browse exposes every materialised alarm object");
|
|
||||||
discovered.ReferenceTypeId.ShouldBe(ReferenceTypeIds.HasComponent);
|
|
||||||
discovered.IsForward.ShouldBeTrue();
|
|
||||||
discovered.NodeClass.ShouldBe(NodeClass.Object);
|
|
||||||
ExpandedNodeId.ToNodeId(discovered.TypeDefinition, session.NamespaceUris).ShouldBe(expectedType);
|
|
||||||
|
|
||||||
var components = await BrowseAsync(session, parent, ReferenceTypeIds.HasComponent, BrowseDirection.Forward, false, ct);
|
|
||||||
components.ShouldContain(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == alarm);
|
|
||||||
var inverse = await BrowseAsync(session, alarm, ReferenceTypeIds.HasComponent, BrowseDirection.Inverse, false, ct);
|
|
||||||
inverse.ShouldContain(reference => !reference.IsForward
|
|
||||||
&& ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == parent);
|
|
||||||
var types = await BrowseAsync(session, alarm, ReferenceTypeIds.HasTypeDefinition, BrowseDirection.Forward, false, ct);
|
|
||||||
types.ShouldContain(reference => ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris) == expectedType);
|
|
||||||
await session.CloseAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<ReferenceDescription>> BrowseAsync(ISession session, NodeId node,
|
|
||||||
NodeId reference, BrowseDirection direction, bool subtypes, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = (await session.BrowseAsync(null, null, 0, [new BrowseDescription
|
|
||||||
{
|
|
||||||
NodeId = node, ReferenceTypeId = reference, BrowseDirection = direction, IncludeSubtypes = subtypes,
|
|
||||||
ResultMask = (uint)BrowseResultMask.All,
|
|
||||||
}], ct)).Results[0];
|
|
||||||
result.StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
result.ContinuationPoint.IsEmpty.ShouldBeTrue();
|
|
||||||
return result.References.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
using Opc.Ua.Security.Certificates;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
||||||
|
|
||||||
/// <summary>Exercises the target certificate manager with real issuer stores and signed certificates.</summary>
|
|
||||||
public sealed class Sdk2CertificateValidationTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData("valid")]
|
|
||||||
[InlineData("expired")]
|
|
||||||
[InlineData("revoked")]
|
|
||||||
[InlineData("missing-issuer")]
|
|
||||||
[InlineData("bad-issuer-usage")]
|
|
||||||
public async Task Trust_and_certificate_failures_keep_the_configured_rejection_policy(string scenario)
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await using var pki = await ValidationPki.CreateAsync(scenario, ct);
|
|
||||||
var result = await pki.Configuration.CertificateManager.ValidateAsync(pki.Leaf, ct: ct);
|
|
||||||
if (scenario == "valid") result.IsValid.ShouldBeTrue(result.ToString());
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result.IsValid.ShouldBeFalse();
|
|
||||||
var expected = scenario switch
|
|
||||||
{
|
|
||||||
"expired" => StatusCodes.BadCertificateTimeInvalid,
|
|
||||||
"revoked" => StatusCodes.BadCertificateRevoked,
|
|
||||||
"missing-issuer" => StatusCodes.BadCertificateChainIncomplete,
|
|
||||||
_ => StatusCodes.BadCertificateIssuerUseNotAllowed,
|
|
||||||
};
|
|
||||||
(result.StatusCode == expected || result.Errors.Any(e => e.StatusCode == expected)).ShouldBeTrue(
|
|
||||||
$"Expected {expected}; actual {result.StatusCode}: {string.Join(", ", result.Errors)}");
|
|
||||||
}
|
|
||||||
pki.Configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(true)]
|
|
||||||
[InlineData(false)]
|
|
||||||
public async Task A_trusted_chain_does_not_bypass_endpoint_identity_checks(bool wrongUri)
|
|
||||||
{
|
|
||||||
await using var pki = await ValidationPki.CreateAsync("valid", TestContext.Current.CancellationToken);
|
|
||||||
(await pki.Configuration.CertificateManager.ValidateAsync(pki.Leaf, ct: TestContext.Current.CancellationToken))
|
|
||||||
.IsValid.ShouldBeTrue();
|
|
||||||
var endpoint = new ConfiguredEndpoint(null, new EndpointDescription
|
|
||||||
{
|
|
||||||
EndpointUrl = wrongUri ? "opc.tcp://localhost:4840" : "opc.tcp://other.invalid:4840",
|
|
||||||
Server = new ApplicationDescription { ApplicationUri = wrongUri ? "urn:wrong-application" : ValidationPki.PeerUri },
|
|
||||||
}, EndpointConfiguration.Create(pki.Configuration));
|
|
||||||
var error = Should.Throw<ServiceResultException>(() =>
|
|
||||||
{
|
|
||||||
if (wrongUri) pki.Configuration.CertificateManager.ValidateApplicationUri(pki.Leaf, endpoint);
|
|
||||||
else pki.Configuration.CertificateManager.ValidateDomains(pki.Leaf, endpoint);
|
|
||||||
});
|
|
||||||
error.StatusCode.ShouldBe(wrongUri ? StatusCodes.BadCertificateUriInvalid : StatusCodes.BadCertificateHostNameInvalid);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ValidationPki : IAsyncDisposable
|
|
||||||
{
|
|
||||||
internal const string PeerUri = "urn:Sdk2ValidationPeer";
|
|
||||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-validation-{Guid.NewGuid():N}");
|
|
||||||
private ApplicationInstance? _application;
|
|
||||||
private Certificate? _issuer;
|
|
||||||
internal Certificate Leaf { get; private set; } = null!;
|
|
||||||
internal ApplicationConfiguration Configuration { get; private set; } = null!;
|
|
||||||
|
|
||||||
internal static async Task<ValidationPki> CreateAsync(string scenario, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var pki = new ValidationPki();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var issuerBuilder = CertificateBuilder.Create("CN=Sdk2ValidationIssuer")
|
|
||||||
.SetNotBefore(now.AddDays(-10)).SetNotAfter(now.AddDays(365)).SetCAConstraint(-1);
|
|
||||||
if (scenario == "bad-issuer-usage")
|
|
||||||
issuerBuilder.AddExtension(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
|
|
||||||
pki._issuer = issuerBuilder.SetRSAKeySize(2048).CreateForRSA();
|
|
||||||
var names = new SubjectAlternativeNameBuilder();
|
|
||||||
names.AddUri(new Uri(PeerUri)); names.AddDnsName("localhost");
|
|
||||||
pki.Leaf = CertificateBuilder.Create("CN=Sdk2ValidationPeer")
|
|
||||||
.SetNotBefore(now.AddDays(-2)).SetNotAfter(scenario == "expired" ? now.AddDays(-1) : now.AddDays(30))
|
|
||||||
.AddExtension(names.Build()).SetIssuer(pki._issuer).SetRSAKeySize(2048).CreateForRSA();
|
|
||||||
if (scenario != "missing-issuer")
|
|
||||||
{
|
|
||||||
var certs = Path.Combine(pki._root, "trusted", "certs"); Directory.CreateDirectory(certs);
|
|
||||||
await File.WriteAllBytesAsync(Path.Combine(certs, "issuer.der"), pki._issuer.RawData, ct);
|
|
||||||
if (scenario == "revoked")
|
|
||||||
{
|
|
||||||
var crls = Path.Combine(pki._root, "trusted", "crl"); Directory.CreateDirectory(crls);
|
|
||||||
var crl = new X509CRL(CrlBuilder.Create(pki._issuer.SubjectName).AddRevokedCertificate(pki.Leaf).CreateForRSA(pki._issuer));
|
|
||||||
await File.WriteAllBytesAsync(Path.Combine(crls, "issuer.crl"), crl.RawData, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
var security = TestClientSecurity.Build(pki._root); security.AutoAcceptUntrustedCertificates = false;
|
|
||||||
pki.Configuration = new ApplicationConfiguration(telemetry)
|
|
||||||
{
|
|
||||||
ApplicationName = "Sdk2Validation", ApplicationUri = "urn:Sdk2Validation", ApplicationType = ApplicationType.Client,
|
|
||||||
SecurityConfiguration = security, ClientConfiguration = new ClientConfiguration(),
|
|
||||||
};
|
|
||||||
pki._application = new ApplicationInstance(telemetry) { ApplicationConfiguration = pki.Configuration };
|
|
||||||
await pki.Configuration.ValidateAsync(ApplicationType.Client, ct);
|
|
||||||
return pki;
|
|
||||||
}
|
|
||||||
catch { await pki.DisposeAsync(); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
if (_application is not null) await _application.DisposeAsync();
|
|
||||||
Leaf?.Dispose(); _issuer?.Dispose();
|
|
||||||
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
||||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
||||||
using CoreReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
||||||
|
|
||||||
/// <summary>Uses activated sessions and the production continuation store; denied resumes never reach a backend.</summary>
|
|
||||||
public sealed class Sdk2HistoryAuthorityWireTests
|
|
||||||
{
|
|
||||||
private const string ValuePath = RawDeviceFolder + "/History";
|
|
||||||
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
public static TheoryData<string, string> Cases => new(
|
|
||||||
from kind in new[] { "raw", "modified", "events" }
|
|
||||||
from change in new[] { "alias", "replace", "rebuild", "grant", "key", "expiry" }
|
|
||||||
select (kind, change));
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[MemberData(nameof(Cases))]
|
|
||||||
public async Task A_saved_cursor_requires_its_live_node_and_current_authority(string kind, string change)
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await using var rig = await StartAsync("HistoryAuthority", encrypted: true, ct);
|
|
||||||
var source = new CountingSource();
|
|
||||||
var clock = new Clock();
|
|
||||||
var key = Key();
|
|
||||||
key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10);
|
|
||||||
var grant = ClusterGrant(KeyPrincipal, "history-allow", NodePermissions.ReadOnly);
|
|
||||||
var state = rig.Enforce(true, [key], grant);
|
|
||||||
rig.Nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true,
|
|
||||||
NullLogger.Instance, clock);
|
|
||||||
rig.Nm.HistorianDataSource = source;
|
|
||||||
Materialise(rig);
|
|
||||||
using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true);
|
|
||||||
var first = await PageAsync(session, kind, alias: false, default, ct);
|
|
||||||
first.StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
first.ContinuationPoint.IsEmpty.ShouldBeFalse("the production session store must own a real next page");
|
|
||||||
source.Calls.ShouldBeGreaterThan(0);
|
|
||||||
|
|
||||||
switch (change)
|
|
||||||
{
|
|
||||||
case "replace":
|
|
||||||
if (kind == "events") rig.Nm.RemoveEquipmentSubtree(RawDeviceFolder, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
||||||
else rig.Nm.RemoveVariableNode(ValuePath, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
||||||
Materialise(rig);
|
|
||||||
break;
|
|
||||||
case "rebuild":
|
|
||||||
rig.Nm.RebuildAddressSpace(); Materialise(rig); rig.Nm.CompleteAddressSpaceApply();
|
|
||||||
break;
|
|
||||||
case "grant": Apply(state, key, []); break;
|
|
||||||
case "key":
|
|
||||||
var disabled = Key(); disabled.Enabled = false; Apply(state, disabled, [grant]); break;
|
|
||||||
case "expiry": clock.Advance(TimeSpan.FromMinutes(20)); break;
|
|
||||||
}
|
|
||||||
var before = source.Calls;
|
|
||||||
var denied = await PageAsync(session, kind, change == "alias", first.ContinuationPoint, ct);
|
|
||||||
denied.StatusCode.ShouldBe(change is "grant" or "key" or "expiry"
|
|
||||||
? StatusCodes.BadUserAccessDenied : StatusCodes.BadContinuationPointInvalid);
|
|
||||||
denied.HistoryData.IsNull.ShouldBeTrue("a denied resume must not disclose saved data");
|
|
||||||
denied.ContinuationPoint.IsEmpty.ShouldBeTrue();
|
|
||||||
source.Calls.ShouldBe(before, "permission and exact-node binding must be checked before backend dispatch");
|
|
||||||
|
|
||||||
// A fresh authorized request remains usable: this is not an always-deny fixture.
|
|
||||||
clock.Reset(); Apply(state, key, [grant]);
|
|
||||||
(await PageAsync(session, kind, alias: false, default, ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
source.Calls.ShouldBeGreaterThan(before);
|
|
||||||
await session.CloseAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Apply(DataPlaneAuthorityState state, ApiKey key, NodeAcl[] grants) =>
|
|
||||||
state.Apply(ServerEventRig.Cluster, Guid.NewGuid().ToString("N"), [key], grants,
|
|
||||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
||||||
|
|
||||||
private static void Materialise(ServerEventRig rig)
|
|
||||||
{
|
|
||||||
rig.MaterialiseNative();
|
|
||||||
rig.Sink.EnsureVariable(ValuePath, RawDeviceFolder, "History", "Double", false, AddressSpaceRealm.Raw, "same-backend-tag");
|
|
||||||
rig.Sink.EnsureVariable(ValuePath, EqA, "HistoryAlias", "Double", false, AddressSpaceRealm.Uns, "same-backend-tag");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ISession session, string kind, bool alias,
|
|
||||||
ByteString point, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var realm = alias ? V3NodeIds.UnsNamespaceUri : V3NodeIds.RawNamespaceUri;
|
|
||||||
var path = kind == "events" ? alias ? EqA : RawDeviceFolder : ValuePath;
|
|
||||||
var node = new NodeId(path, (ushort)session.NamespaceUris.GetIndex(realm));
|
|
||||||
HistoryReadDetails details = kind == "events" ? new ReadEventDetails
|
|
||||||
{
|
|
||||||
StartTime = Epoch, EndTime = Epoch.AddMinutes(1), NumValuesPerNode = 2,
|
|
||||||
Filter = new EventFilter { SelectClauses =
|
|
||||||
[new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.EventId))] },
|
|
||||||
} : new ReadRawModifiedDetails
|
|
||||||
{ StartTime = Epoch, EndTime = Epoch.AddMinutes(1), NumValuesPerNode = 2, IsReadModified = kind == "modified" };
|
|
||||||
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
||||||
[new HistoryReadValueId { NodeId = node, ContinuationPoint = point }], ct);
|
|
||||||
return response.Results.ToList().ShouldHaveSingleItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class Clock : TimeProvider
|
|
||||||
{
|
|
||||||
private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow;
|
|
||||||
private TimeSpan _offset;
|
|
||||||
public override DateTimeOffset GetUtcNow() => _initial + _offset;
|
|
||||||
public void Advance(TimeSpan amount) => _offset += amount;
|
|
||||||
public void Reset() => _offset = TimeSpan.Zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CountingSource : IHistorianDataSource
|
|
||||||
{
|
|
||||||
private int _calls;
|
|
||||||
public int Calls => Volatile.Read(ref _calls);
|
|
||||||
private static DataValueSnapshot Value(int i) => new((double)i, OpcUaStatus.Good, Epoch.AddSeconds(i), Epoch.AddSeconds(i));
|
|
||||||
public Task<CoreReadResult> ReadRawAsync(string reference, DateTime start, DateTime end, uint max, CancellationToken ct)
|
|
||||||
{
|
|
||||||
Interlocked.Increment(ref _calls);
|
|
||||||
var rows = Enumerable.Range(0, 25).Select(Value).Where(x => x.SourceTimestampUtc >= start && x.SourceTimestampUtc < end).ToList();
|
|
||||||
var count = max == 0 ? rows.Count : checked((int)max);
|
|
||||||
return Task.FromResult(new CoreReadResult(rows.Take(count).ToList(), rows.Count > count ? [1] : null));
|
|
||||||
}
|
|
||||||
public Task<HistoryModifiedReadResult> ReadModifiedAsync(string reference, DateTime start, DateTime end,
|
|
||||||
uint max, HistoryModifiedCursor? resume, CancellationToken ct)
|
|
||||||
{
|
|
||||||
Interlocked.Increment(ref _calls);
|
|
||||||
var rows = Enumerable.Range(0, 25).Where(i => Epoch.AddSeconds(i) >= start && Epoch.AddSeconds(i) < end
|
|
||||||
&& (resume is null || i > resume.ModifiedId))
|
|
||||||
.Select(i => new HistoryModifiedValue(Value(i), Epoch.AddMinutes(2), HistoryModificationType.Replace,
|
|
||||||
"fixture", i, Epoch.AddSeconds(i))).ToList();
|
|
||||||
var count = max == 0 ? rows.Count : checked((int)max);
|
|
||||||
return Task.FromResult(new HistoryModifiedReadResult(rows.Take(count).ToList(), rows.Count > count));
|
|
||||||
}
|
|
||||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? source, DateTime start, DateTime end, int max, CancellationToken ct)
|
|
||||||
{
|
|
||||||
Interlocked.Increment(ref _calls);
|
|
||||||
var rows = Enumerable.Range(0, 25).Select(i => new HistoricalEvent($"e{i:00}", "src", Epoch.AddSeconds(i), Epoch, "m", 500))
|
|
||||||
.Where(x => x.EventTimeUtc >= start && x.EventTimeUtc < end).ToList();
|
|
||||||
var count = max <= 0 ? rows.Count : max;
|
|
||||||
return Task.FromResult(new HistoricalEventsResult(rows.Take(count).ToList(), rows.Count > count ? [1] : null));
|
|
||||||
}
|
|
||||||
public Task<CoreReadResult> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan interval, HistoryAggregateType a, CancellationToken ct) =>
|
|
||||||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, interval, a, ct);
|
|
||||||
public Task<CoreReadResult> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> times, CancellationToken ct) =>
|
|
||||||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, times, ct);
|
|
||||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-542
@@ -1,542 +0,0 @@
|
|||||||
using System.Collections;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
using Opc.Ua.Server;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
||||||
using ClientSession = Opc.Ua.Client.ISession;
|
|
||||||
using Subscription = Opc.Ua.Client.Subscription;
|
|
||||||
using MonitoredItem = Opc.Ua.Client.MonitoredItem;
|
|
||||||
using ServerSession = Opc.Ua.Server.ISession;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opt-in Task 12 lifecycle evidence. Diagnostic mode is deliberately separate from the two-hour gate.
|
|
||||||
/// Run only this test in an otherwise idle test process; RSS/FD counts describe the whole process.
|
|
||||||
/// No key, certificate, store path, token, or OPC UA payload is written to the JSONL evidence.
|
|
||||||
/// </summary>
|
|
||||||
[Trait("Category", "E2E")]
|
|
||||||
public sealed class Sdk2LifecycleSoakTests
|
|
||||||
{
|
|
||||||
private const string Scalar = ServerEventRig.RawDeviceFolder + "/SoakScalar";
|
|
||||||
private const string ArrayTag = ServerEventRig.RawDeviceFolder + "/SoakArray";
|
|
||||||
private const string UnsScalar = "SoakEquipment/Scalar";
|
|
||||||
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
public static bool Enabled => Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK") == "1";
|
|
||||||
|
|
||||||
[Fact(SkipUnless = nameof(Enabled), Skip = "Opt in with OTOPCUA_SDK2_SOAK=1 and an explicit diagnostic/full mode.")]
|
|
||||||
public async Task Repeated_lifecycles_return_resources_to_a_steady_baseline()
|
|
||||||
{
|
|
||||||
var mode = Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_MODE");
|
|
||||||
(mode is "diagnostic" or "full").ShouldBeTrue("explicit diagnostic or full mode is required");
|
|
||||||
var seconds = int.Parse(Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_SECONDS")
|
|
||||||
?? (mode == "full" ? "7200" : "90"), CultureInfo.InvariantCulture);
|
|
||||||
if (mode == "full") seconds.ShouldBeGreaterThanOrEqualTo(7200);
|
|
||||||
else seconds.ShouldBeInRange(60, 120);
|
|
||||||
var output = Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_OUTPUT")
|
|
||||||
?? throw new InvalidOperationException("An absolute, fresh OTOPCUA_SDK2_SOAK_OUTPUT JSONL path is required.");
|
|
||||||
Path.IsPathFullyQualified(output).ShouldBeTrue();
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
|
|
||||||
await using var evidence = new StreamWriter(new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.Read));
|
|
||||||
using var run = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
||||||
run.CancelAfter(TimeSpan.FromSeconds(seconds + 120));
|
|
||||||
var ct = run.Token;
|
|
||||||
var watch = Stopwatch.StartNew();
|
|
||||||
var clientRoot = Path.Combine(Path.GetTempPath(), $"otua-soak-client-{Guid.NewGuid():N}");
|
|
||||||
ApplicationInstance? application = null;
|
|
||||||
ServerEventRig? rig = null;
|
|
||||||
string? serverRoot = null;
|
|
||||||
var cycles = 0;
|
|
||||||
var restarts = 0;
|
|
||||||
Sample? baseline = null;
|
|
||||||
var retained = new Queue<Sample>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
var security = TestClientSecurity.Build(clientRoot);
|
|
||||||
security.ApplicationCertificate!.SubjectName = "CN=SDK2LifecycleSoak";
|
|
||||||
var config = new ApplicationConfiguration(telemetry)
|
|
||||||
{
|
|
||||||
ApplicationName = "SDK2LifecycleSoak", ApplicationUri = $"urn:SDK2LifecycleSoak:{Guid.NewGuid():N}",
|
|
||||||
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
|
|
||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15_000 },
|
|
||||||
TransportQuotas = new TransportQuotas { OperationTimeout = 5000 },
|
|
||||||
};
|
|
||||||
application = new ApplicationInstance(telemetry) { ApplicationConfiguration = config };
|
|
||||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
||||||
await application.CheckApplicationInstanceCertificatesAsync(true, null, ct);
|
|
||||||
rig = await ServerEventRig.StartAsync("SDK2LifecycleSoak", encrypted: true, ct);
|
|
||||||
serverRoot = RequiredField<string>(rig, "_pkiRoot");
|
|
||||||
Configure(rig);
|
|
||||||
// The required observation period excludes initial certificate creation and server startup.
|
|
||||||
watch.Restart();
|
|
||||||
await LogAsync(evidence, new
|
|
||||||
{
|
|
||||||
kind = "start", utc = DateTimeOffset.UtcNow, processId = Environment.ProcessId,
|
|
||||||
mode, requestedSeconds = seconds, minimumFullSeconds = 7200,
|
|
||||||
build = Environment.GetEnvironmentVariable("OTOPCUA_SOAK_BUILD_SHA"),
|
|
||||||
sdk = typeof(OperationContext).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
|
|
||||||
diagnosticInstrumentation = "SDK2.0.1 CP lists and certificate-validation cached stores; read-only",
|
|
||||||
limits = new { retainedManagedGrowthBytes = 64L << 20, rssGrowthBytes = 128L << 20, fdGrowth = 32 },
|
|
||||||
coverage = "encrypted sessions; scalar/Int16[] Raw+UNS; subscription removal; browse abandonment; history release/cross-session/close; surgical changes/rebuild; revocation; paused TCP read/recovery; same-PKI restart",
|
|
||||||
});
|
|
||||||
while (watch.Elapsed < TimeSpan.FromSeconds(seconds))
|
|
||||||
{
|
|
||||||
var cycle = Stopwatch.StartNew();
|
|
||||||
await ExerciseAsync(rig, config, ++cycles, evidence, ct);
|
|
||||||
await EventuallyAsync(() => PublicCounts(rig) == (0, 0, 0), ct);
|
|
||||||
var sample = Capture(rig, config.CertificateManager, watch.Elapsed, cycles, clientRoot, serverRoot);
|
|
||||||
sample.Sessions.ShouldBe(0); sample.Subscriptions.ShouldBe(0); sample.Items.ShouldBe(0);
|
|
||||||
sample.BrowsePoints.ShouldBe(0); sample.HistoryPoints.ShouldBe(0);
|
|
||||||
sample.LiveDataItems.ShouldBe(0); sample.LiveEventItems.ShouldBe(0); sample.MonitoredNodes.ShouldBe(0);
|
|
||||||
sample.PkiDescriptors.ShouldBe(0, "quiescent certificate file descriptors must be closed");
|
|
||||||
if (cycles >= 3)
|
|
||||||
{
|
|
||||||
baseline ??= sample;
|
|
||||||
sample.CachedCertificateStores.ShouldBe(baseline.CachedCertificateStores);
|
|
||||||
sample.Variables.ShouldBe(baseline.Variables); sample.Folders.ShouldBe(baseline.Folders);
|
|
||||||
sample.Conditions.ShouldBe(baseline.Conditions);
|
|
||||||
sample.Descriptors.ShouldBeLessThanOrEqualTo(baseline.Descriptors + 32);
|
|
||||||
sample.ManagedBytes.ShouldBeLessThanOrEqualTo(baseline.ManagedBytes + (64L << 20));
|
|
||||||
sample.RssBytes.ShouldBeLessThanOrEqualTo(baseline.RssBytes + (128L << 20));
|
|
||||||
retained.Enqueue(sample);
|
|
||||||
if (retained.Count > 24) retained.Dequeue();
|
|
||||||
if (retained.Count == 24)
|
|
||||||
{
|
|
||||||
var window = retained.ToArray();
|
|
||||||
var strictlyIncreasing = window.Zip(window.Skip(1)).All(pair => pair.Second.ManagedBytes > pair.First.ManagedBytes);
|
|
||||||
(strictlyIncreasing && window[^1].ManagedBytes - window[0].ManagedBytes > (16L << 20)).ShouldBeFalse(
|
|
||||||
"retained managed memory must not grow monotonically across 24 quiescent cycles");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await LogAsync(evidence, new { kind = "quiescent", cycleSeconds = cycle.Elapsed.TotalSeconds, resources = sample });
|
|
||||||
if (cycles % 4 == 0)
|
|
||||||
{
|
|
||||||
var restart = Stopwatch.StartNew();
|
|
||||||
await RestartWithActiveResourcesAsync(rig, config, ct).WaitAsync(TimeSpan.FromSeconds(25), ct);
|
|
||||||
Configure(rig);
|
|
||||||
restarts++;
|
|
||||||
await LogAsync(evidence, new { kind = "restart", cycles, restarts, seconds = restart.Elapsed.TotalSeconds });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cycles.ShouldBeGreaterThanOrEqualTo(4, "diagnostic mode must exercise at least one actual restart");
|
|
||||||
restarts.ShouldBeGreaterThan(0);
|
|
||||||
watch.Elapsed.TotalSeconds.ShouldBeGreaterThanOrEqualTo(seconds);
|
|
||||||
var finalClientCores = CertificateCores(config.CertificateManager);
|
|
||||||
var finalServerCores = CertificateCores(rig.Server.CertificateManager!);
|
|
||||||
await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
|
|
||||||
rig = null;
|
|
||||||
await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
|
|
||||||
application = null;
|
|
||||||
finalClientCores.Sum(CachedStores).ShouldBe(0);
|
|
||||||
finalServerCores.Sum(CachedStores).ShouldBe(0);
|
|
||||||
DescriptorCounts(clientRoot, serverRoot).Pki.ShouldBe(0);
|
|
||||||
await LogAsync(evidence, new { kind = "complete", utc = DateTimeOffset.UtcNow, mode, cycles, restarts, seconds = watch.Elapsed.TotalSeconds,
|
|
||||||
satisfiesTwoHourDuration = mode == "full" && watch.Elapsed.TotalSeconds >= 7200 });
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
await LogAsync(evidence, new { kind = "failed", cycles, restarts, seconds = watch.Elapsed.TotalSeconds,
|
|
||||||
exceptionType = error.GetType().FullName });
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken);
|
|
||||||
if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken);
|
|
||||||
if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true);
|
|
||||||
if (serverRoot is not null) Directory.Exists(serverRoot).ShouldBeFalse("the task-owned server PKI must be released");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Configure(ServerEventRig rig)
|
|
||||||
{
|
|
||||||
rig.MaterialiseNative();
|
|
||||||
rig.Nm.HistorianDataSource = new EventHistoryPagingOverTheWireTests.SqlLikeEventSource(Enumerable.Range(0, 40)
|
|
||||||
.Select(i => new HistoricalEvent($"soak-{i}", "source", Epoch.AddSeconds(i), Epoch, "soak", 500)).ToList());
|
|
||||||
rig.Nm.NodeWriteGateway = new AcceptWrites();
|
|
||||||
MaterialiseValues(rig);
|
|
||||||
rig.Enforce(true, [ServerEventRig.Key()], Grant());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl Grant() => ServerEventRig.ClusterGrant(
|
|
||||||
ServerEventRig.KeyPrincipal, "soak-grant", NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe
|
|
||||||
| NodePermissions.WriteOperate | NodePermissions.HistoryRead);
|
|
||||||
|
|
||||||
private static void MaterialiseValues(ServerEventRig rig)
|
|
||||||
{
|
|
||||||
rig.Sink.EnsureFolder(ServerEventRig.RawDeviceFolder, null, "Device", AddressSpaceRealm.Raw);
|
|
||||||
rig.Sink.EnsureVariable(Scalar, ServerEventRig.RawDeviceFolder, "Scalar", "Int32", true, AddressSpaceRealm.Raw);
|
|
||||||
rig.Sink.EnsureVariable(ArrayTag, ServerEventRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw,
|
|
||||||
isArray: true, arrayLength: 4);
|
|
||||||
rig.Sink.EnsureFolder("SoakEquipment", null, "Equipment", AddressSpaceRealm.Uns);
|
|
||||||
rig.Sink.EnsureVariable(UnsScalar, "SoakEquipment", "Scalar", "Int32", true, AddressSpaceRealm.Uns);
|
|
||||||
for (var i = 0; i < 16; i++) rig.Sink.EnsureVariable($"{ServerEventRig.RawDeviceFolder}/Browse{i}",
|
|
||||||
ServerEventRig.RawDeviceFolder, $"Browse{i}", "Int32", false, AddressSpaceRealm.Raw);
|
|
||||||
rig.Sink.WriteValue(Scalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
||||||
rig.Sink.WriteValue(ArrayTag, new short[] { 0, 1, 2, 3 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
||||||
rig.Sink.WriteValue(UnsScalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ExerciseAsync(ServerEventRig rig, ApplicationConfiguration config, int cycle,
|
|
||||||
StreamWriter evidence, CancellationToken ct)
|
|
||||||
{
|
|
||||||
await using var proxy = new PauseProxy(new Uri(rig.EndpointUrl).Port);
|
|
||||||
using var owner = await ConnectAsync(rig, config, proxy.Port, ct);
|
|
||||||
using var other = await ConnectAsync(rig, config, null, ct);
|
|
||||||
var ownedSessions = rig.Server.CurrentInstance.SessionManager.GetSessions().ToArray();
|
|
||||||
ownedSessions.Length.ShouldBe(2);
|
|
||||||
var scalar = Raw(owner, Scalar);
|
|
||||||
var array = Raw(owner, ArrayTag);
|
|
||||||
var unsNs = owner.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
|
||||||
unsNs.ShouldBeGreaterThan(0);
|
|
||||||
var uns = new NodeId(UnsScalar, (ushort)unsNs);
|
|
||||||
owner.NamespaceUris.GetString(scalar.NamespaceIndex).ShouldBe(V3NodeIds.RawNamespaceUri);
|
|
||||||
owner.NamespaceUris.GetString(uns.NamespaceIndex).ShouldBe(V3NodeIds.UnsNamespaceUri);
|
|
||||||
using var subscription = new Subscription(owner.DefaultSubscription) { PublishingInterval = 100, KeepAliveCount = 10 };
|
|
||||||
owner.AddSubscription(subscription);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await subscription.CreateAsync(ct);
|
|
||||||
var seen = new ConcurrentDictionary<NodeId, Variant>();
|
|
||||||
foreach (var id in new[] { scalar, array, uns })
|
|
||||||
{
|
|
||||||
var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = id, AttributeId = Attributes.Value,
|
|
||||||
SamplingInterval = 50, QueueSize = 10 };
|
|
||||||
item.Notification += (sender, _) =>
|
|
||||||
{
|
|
||||||
foreach (var value in sender.DequeueValues()) if (StatusCode.IsGood(value.StatusCode)) seen[id] = value.WrappedValue;
|
|
||||||
};
|
|
||||||
subscription.AddItem(item);
|
|
||||||
}
|
|
||||||
await subscription.ApplyChangesAsync(ct);
|
|
||||||
subscription.MonitoredItems.ShouldAllBe(item => ServiceResult.IsGood(item.Status.Error));
|
|
||||||
PublicCounts(rig).ShouldBe((2, 1, 3));
|
|
||||||
var values = new short[] { (short)(cycle % 30000), 2, 3, 4 };
|
|
||||||
var writes = await owner.WriteAsync(null, new[]
|
|
||||||
{
|
|
||||||
new WriteValue { NodeId = scalar, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(cycle)) },
|
|
||||||
new WriteValue { NodeId = array, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(values)) },
|
|
||||||
new WriteValue { NodeId = uns, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(cycle)) },
|
|
||||||
}, ct);
|
|
||||||
writes.Results.ToList().ShouldAllBe(status => status == StatusCodes.Good);
|
|
||||||
(await owner.ReadValueAsync(scalar, ct)).WrappedValue.GetInt32().ShouldBe(cycle);
|
|
||||||
(await owner.ReadValueAsync(array, ct)).ClrValue().ShouldBeOfType<short[]>().ShouldBe(values);
|
|
||||||
(await owner.ReadValueAsync(uns, ct)).WrappedValue.GetInt32().ShouldBe(cycle);
|
|
||||||
await EventuallyAsync(() => seen.TryGetValue(scalar, out var value) && value.GetInt32() == cycle
|
|
||||||
&& seen.ContainsKey(array) && seen.TryGetValue(uns, out var mirror) && mirror.GetInt32() == cycle, ct);
|
|
||||||
|
|
||||||
// One explicitly released and one deliberately abandoned browse point.
|
|
||||||
var browse = await BrowseAsync(owner, ct);
|
|
||||||
browse.IsEmpty.ShouldBeFalse();
|
|
||||||
(await owner.BrowseNextAsync(null, true, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
(await BrowseAsync(owner, ct)).IsEmpty.ShouldBeFalse();
|
|
||||||
// Production history points: cross-session denial, single-use resume, explicit release and close cleanup.
|
|
||||||
var first = (await PageAsync(owner, default, false, ct)).ContinuationPoint;
|
|
||||||
first.IsEmpty.ShouldBeFalse();
|
|
||||||
(await PageAsync(other, first, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
|
||||||
var next = (await PageAsync(owner, first, false, ct)).ContinuationPoint;
|
|
||||||
next.IsEmpty.ShouldBeFalse();
|
|
||||||
(await PageAsync(owner, next, true, ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
for (var i = 0; i < 6; i++) (await PageAsync(owner, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse();
|
|
||||||
var held = ownedSessions.Select(ContinuationCounts).ToArray();
|
|
||||||
held.Sum(count => count.Browse).ShouldBeGreaterThan(0);
|
|
||||||
held.Sum(count => count.History).ShouldBeGreaterThan(0);
|
|
||||||
var active = PublicCounts(rig);
|
|
||||||
await LogAsync(evidence, new { kind = "active", cycle, sessions = active.Sessions,
|
|
||||||
subscriptions = active.Subscriptions, items = active.Items, browsePoints = held.Sum(x => x.Browse), historyPoints = held.Sum(x => x.History) });
|
|
||||||
|
|
||||||
rig.Nm.UpdateTagAttributes(Scalar, false, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
||||||
rig.Nm.UpdateTagAttributes(Scalar, true, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
||||||
rig.Nm.RemoveVariableNode(ArrayTag, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
||||||
rig.Sink.EnsureVariable(ArrayTag, ServerEventRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw,
|
|
||||||
isArray: true, arrayLength: 4);
|
|
||||||
rig.Sink.WriteValue(ArrayTag, values, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
||||||
var authority = rig.Nm.DataPlaneAuthority.ShouldBeOfType<DataPlaneAuthorityState>();
|
|
||||||
authority.Apply(ServerEventRig.Cluster, $"revoked-{cycle}", [], [Grant()],
|
|
||||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
||||||
var denied = await owner.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
|
||||||
[new ReadValueId { NodeId = scalar, AttributeId = Attributes.Value }], ct);
|
|
||||||
denied.Results[0].StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
||||||
rig.Enforce(true, [ServerEventRig.Key()], Grant());
|
|
||||||
|
|
||||||
proxy.Pause();
|
|
||||||
var stalled = Stopwatch.StartNew();
|
|
||||||
string stalledStatus;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(1));
|
|
||||||
await owner.ReadValueAsync(scalar, timeout.Token).WaitAsync(TimeSpan.FromSeconds(5), ct);
|
|
||||||
throw new InvalidOperationException("The deliberately paused network unexpectedly completed the read.");
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { stalledStatus = "OperationCanceled"; }
|
|
||||||
// Exact-tag ChannelAsyncOperation maps a canceled completion waiter to BadRequestInterrupted.
|
|
||||||
catch (ServiceResultException error) when (error.StatusCode == StatusCodes.BadTimeout
|
|
||||||
|| error.StatusCode == StatusCodes.BadRequestCancelledByClient || error.StatusCode == StatusCodes.BadRequestTimeout
|
|
||||||
|| error.StatusCode == StatusCodes.BadRequestInterrupted) { stalledStatus = error.StatusCode.ToString(); }
|
|
||||||
finally { proxy.Resume(); }
|
|
||||||
(await owner.ReadValueAsync(scalar, ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
await LogAsync(evidence, new { kind = "network-stall-recovered", cycle, status = stalledStatus, seconds = stalled.Elapsed.TotalSeconds });
|
|
||||||
await subscription.DeleteAsync(true, ct);
|
|
||||||
await owner.RemoveSubscriptionAsync(subscription, ct);
|
|
||||||
rig.Sink.RebuildAddressSpace();
|
|
||||||
Configure(rig);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
proxy.Resume();
|
|
||||||
await owner.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct);
|
|
||||||
await other.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct);
|
|
||||||
foreach (var closed in ownedSessions)
|
|
||||||
{
|
|
||||||
ContinuationCounts(closed).ShouldBe((0, 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RestartWithActiveResourcesAsync(ServerEventRig rig, ApplicationConfiguration config,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
using var client = await ConnectAsync(rig, config, null, ct);
|
|
||||||
using var subscription = new Subscription(client.DefaultSubscription) { PublishingInterval = 100 };
|
|
||||||
client.AddSubscription(subscription);
|
|
||||||
await subscription.CreateAsync(ct);
|
|
||||||
subscription.AddItem(new MonitoredItem(subscription.DefaultItem)
|
|
||||||
{
|
|
||||||
StartNodeId = Raw(client, Scalar), AttributeId = Attributes.Value, SamplingInterval = 50,
|
|
||||||
});
|
|
||||||
await subscription.ApplyChangesAsync(ct);
|
|
||||||
PublicCounts(rig).ShouldBe((1, 1, 1));
|
|
||||||
(await BrowseAsync(client, ct)).IsEmpty.ShouldBeFalse();
|
|
||||||
(await PageAsync(client, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse();
|
|
||||||
var oldInternal = rig.Server.CurrentInstance;
|
|
||||||
var oldSessionManager = oldInternal.SessionManager;
|
|
||||||
var oldSubscriptionManager = oldInternal.SubscriptionManager;
|
|
||||||
var oldSessions = oldSessionManager.GetSessions().ToArray();
|
|
||||||
var oldSubscriptions = oldSubscriptionManager.GetSubscriptions().ToArray();
|
|
||||||
var oldManager = rig.Server.CertificateManager!;
|
|
||||||
var oldCores = CertificateCores(oldManager);
|
|
||||||
await rig.RestartAsync(ct).WaitAsync(TimeSpan.FromSeconds(20), ct);
|
|
||||||
oldSessionManager.GetSessions().ShouldBeEmpty();
|
|
||||||
oldSubscriptionManager.GetSubscriptions().ShouldBeEmpty();
|
|
||||||
oldSubscriptions.ShouldAllBe(item => item.MonitoredItemCount == 0);
|
|
||||||
foreach (var session in oldSessions) ContinuationCounts(session).ShouldBe((0, 0));
|
|
||||||
RequiredField<bool>(oldManager, "m_disposed").ShouldBeTrue();
|
|
||||||
oldCores.Sum(CachedStores).ShouldBe(0, "stopped server validators must release every cached store");
|
|
||||||
PublicCounts(rig).ShouldBe((0, 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ClientSession> ConnectAsync(ServerEventRig rig, ApplicationConfiguration config,
|
|
||||||
int? proxyPort, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, rig.EndpointUrl, true, telemetry, ct)
|
|
||||||
?? throw new InvalidOperationException("No encrypted endpoint.");
|
|
||||||
endpoint.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
|
||||||
if (proxyPort is { } port) endpoint.EndpointUrl = $"opc.tcp://127.0.0.1:{port}/OtOpcUa";
|
|
||||||
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
|
|
||||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, false,
|
|
||||||
"SDK2LifecycleSoak", 15_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(ServerEventRig.KeyPassword)), default, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static NodeId Raw(ClientSession session, string id)
|
|
||||||
{
|
|
||||||
var ns = session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
||||||
ns.ShouldBeGreaterThan(0);
|
|
||||||
return new NodeId(id, (ushort)ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ByteString> BrowseAsync(ClientSession session, CancellationToken ct) =>
|
|
||||||
(await session.BrowseAsync(null, null, 1, [new BrowseDescription
|
|
||||||
{
|
|
||||||
NodeId = Raw(session, ServerEventRig.RawDeviceFolder), BrowseDirection = BrowseDirection.Forward,
|
|
||||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true,
|
|
||||||
ResultMask = (uint)BrowseResultMask.All,
|
|
||||||
}], ct)).Results[0].ContinuationPoint;
|
|
||||||
|
|
||||||
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ClientSession session, ByteString point,
|
|
||||||
bool release, CancellationToken ct) => (await session.HistoryReadAsync(null, new ExtensionObject(new ReadEventDetails
|
|
||||||
{
|
|
||||||
StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 4,
|
|
||||||
Filter = new EventFilter { SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType,
|
|
||||||
QualifiedName.From(BrowseNames.EventId))] },
|
|
||||||
}), TimestampsToReturn.Both, release,
|
|
||||||
[new HistoryReadValueId { NodeId = Raw(session, ServerEventRig.RawDeviceFolder), ContinuationPoint = point }], ct)).Results[0];
|
|
||||||
|
|
||||||
private static (int Sessions, int Subscriptions, int Items) PublicCounts(ServerEventRig rig)
|
|
||||||
{
|
|
||||||
var subscriptions = rig.Server.CurrentInstance.SubscriptionManager.GetSubscriptions();
|
|
||||||
return (rig.Server.CurrentInstance.SessionManager.GetSessions().Count, subscriptions.Count,
|
|
||||||
subscriptions.Sum(subscription => subscription.MonitoredItemCount));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact-tag diagnostic instrumentation only. Fail if fields/types change; never replace or mutate a store.
|
|
||||||
private static (int Browse, int History) ContinuationCounts(ServerSession session)
|
|
||||||
{
|
|
||||||
var points = session.ContinuationPoints;
|
|
||||||
using (RequiredField<System.Threading.Lock>(points, "m_lock").EnterScope())
|
|
||||||
return (NullableCollectionCount(points, "m_browse"), NullableCollectionCount(points, "m_history"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object[] CertificateCores(object manager)
|
|
||||||
{
|
|
||||||
using (RequiredField<System.Threading.Lock>(manager, "m_certificatesLock").EnterScope())
|
|
||||||
{
|
|
||||||
var cores = new List<object>();
|
|
||||||
foreach (var field in new[] { "m_peerCore", "m_userCore", "m_httpsCore" })
|
|
||||||
if (Field(manager, field).GetValue(manager) is { } core) cores.Add(core);
|
|
||||||
cores.AddRange(RequiredField<IDictionary>(manager, "m_customCores").Values.Cast<object>());
|
|
||||||
return cores.Distinct(System.Collections.Generic.ReferenceEqualityComparer.Instance).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CachedStores(object core) => RequiredField<IDictionary>(core, "m_stores").Count;
|
|
||||||
private static int NullableCollectionCount(object owner, string name) => Field(owner, name).GetValue(owner) switch
|
|
||||||
{
|
|
||||||
null => 0,
|
|
||||||
ICollection items => items.Count,
|
|
||||||
_ => throw new InvalidOperationException($"SDK diagnostic field {name} no longer has the expected collection type."),
|
|
||||||
};
|
|
||||||
private static FieldInfo Field(object owner, string name) => owner.GetType().GetField(name,
|
|
||||||
BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Missing SDK diagnostic field {name}.");
|
|
||||||
private static T RequiredField<T>(object owner, string name) => Field(owner, name).GetValue(owner) is T value ? value
|
|
||||||
: throw new InvalidOperationException($"SDK diagnostic field {name} no longer has the expected type {typeof(T).Name}.");
|
|
||||||
|
|
||||||
private sealed record Sample(int Cycle, double Seconds, int Sessions, int Subscriptions, int Items,
|
|
||||||
int BrowsePoints, int HistoryPoints, int LiveDataItems, int LiveEventItems, int MonitoredNodes,
|
|
||||||
int Variables, int Folders, int Conditions, int CachedCertificateStores,
|
|
||||||
int Descriptors, int PkiDescriptors, long ManagedBytes, long RssBytes, long AllocatedBytes,
|
|
||||||
int Gen0, int Gen1, int Gen2);
|
|
||||||
|
|
||||||
private static Sample Capture(ServerEventRig rig, object clientManager, TimeSpan elapsed, int cycle,
|
|
||||||
string clientRoot, string serverRoot)
|
|
||||||
{
|
|
||||||
var counts = PublicCounts(rig);
|
|
||||||
var points = rig.Server.CurrentInstance.SessionManager.GetSessions().Select(ContinuationCounts).ToArray();
|
|
||||||
var descriptors = DescriptorCounts(clientRoot, serverRoot);
|
|
||||||
var retained = GC.GetTotalMemory(forceFullCollection: true);
|
|
||||||
using var process = Process.GetCurrentProcess();
|
|
||||||
process.Refresh();
|
|
||||||
return new Sample(cycle, elapsed.TotalSeconds, counts.Sessions, counts.Subscriptions, counts.Items,
|
|
||||||
points.Sum(x => x.Browse), points.Sum(x => x.History), rig.Nm.LiveItemCount, rig.Nm.LiveEventItemCount,
|
|
||||||
rig.Nm.MonitoredNodeCount, rig.Nm.VariableCount, rig.Nm.FolderCount, rig.Nm.AlarmConditionCount,
|
|
||||||
CertificateCores(rig.Server.CertificateManager!).Sum(CachedStores) + CertificateCores(clientManager).Sum(CachedStores),
|
|
||||||
descriptors.Total, descriptors.Pki, retained, process.WorkingSet64, GC.GetTotalAllocatedBytes(),
|
|
||||||
GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (int Total, int Pki) DescriptorCounts(params string[] roots)
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
var targets = Directory.EnumerateFileSystemEntries("/proc/self/fd").Select(path => new FileInfo(path).LinkTarget).ToArray();
|
|
||||||
return (targets.Length, targets.Count(path => path is not null && roots.Any(root => path.StartsWith(root, StringComparison.Ordinal))));
|
|
||||||
}
|
|
||||||
if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
using var process = new Process { StartInfo = new ProcessStartInfo("/usr/sbin/lsof")
|
|
||||||
{ RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } };
|
|
||||||
foreach (var arg in new[] { "-a", "-p", Environment.ProcessId.ToString(CultureInfo.InvariantCulture), "-Fnf" })
|
|
||||||
process.StartInfo.ArgumentList.Add(arg);
|
|
||||||
process.Start();
|
|
||||||
var text = process.StandardOutput.ReadToEnd();
|
|
||||||
_ = process.StandardError.ReadToEnd();
|
|
||||||
if (!process.WaitForExit(5000)) { process.Kill(); throw new TimeoutException("Descriptor probe stalled."); }
|
|
||||||
process.ExitCode.ShouldBe(0);
|
|
||||||
var lines = text.Split('\n');
|
|
||||||
return (lines.Count(line => line.Length > 1 && line[0] == 'f' && char.IsDigit(line[1])),
|
|
||||||
lines.Count(line => line.StartsWith('n') && roots.Any(root => line.AsSpan(1).StartsWith(root, StringComparison.Ordinal))));
|
|
||||||
}
|
|
||||||
throw new PlatformNotSupportedException("This soak requires Linux procfs or macOS lsof for actual descriptor evidence.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task EventuallyAsync(Func<bool> condition, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var deadline = Stopwatch.StartNew();
|
|
||||||
while (!condition() && deadline.Elapsed < TimeSpan.FromSeconds(10)) await Task.Delay(25, ct);
|
|
||||||
condition().ShouldBeTrue("resource or notification condition did not settle within ten seconds");
|
|
||||||
}
|
|
||||||
private static async Task LogAsync(StreamWriter output, object value)
|
|
||||||
{
|
|
||||||
await output.WriteLineAsync(JsonSerializer.Serialize(value));
|
|
||||||
await output.FlushAsync();
|
|
||||||
}
|
|
||||||
private sealed class AcceptWrites : IOpcUaNodeWriteGateway
|
|
||||||
{
|
|
||||||
public Task<NodeWriteOutcome> WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
|
||||||
Task.FromResult(new NodeWriteOutcome(true, null));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Task-owned TCP relay. Pause blocks forwarding in both directions without closing either socket.</summary>
|
|
||||||
private sealed class PauseProxy : IAsyncDisposable
|
|
||||||
{
|
|
||||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
|
||||||
private readonly CancellationTokenSource _stop = new();
|
|
||||||
private readonly ConcurrentBag<TcpClient> _sockets = [];
|
|
||||||
private readonly ConcurrentBag<Task> _pumps = [];
|
|
||||||
private readonly Task _accept;
|
|
||||||
private TaskCompletionSource? _pause;
|
|
||||||
internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
|
||||||
internal PauseProxy(int targetPort)
|
|
||||||
{
|
|
||||||
_listener.Start();
|
|
||||||
_accept = AcceptAsync(targetPort);
|
|
||||||
}
|
|
||||||
internal void Pause() => Volatile.Write(ref _pause, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
|
|
||||||
internal void Resume() => Interlocked.Exchange(ref _pause, null)?.TrySetResult();
|
|
||||||
private async Task AcceptAsync(int port)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (!_stop.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
var incoming = await _listener.AcceptTcpClientAsync(_stop.Token);
|
|
||||||
_sockets.Add(incoming);
|
|
||||||
var outgoing = new TcpClient(); _sockets.Add(outgoing);
|
|
||||||
await outgoing.ConnectAsync(IPAddress.Loopback, port, _stop.Token);
|
|
||||||
_pumps.Add(PumpAsync(incoming.GetStream(), outgoing.GetStream()));
|
|
||||||
_pumps.Add(PumpAsync(outgoing.GetStream(), incoming.GetStream()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
|
|
||||||
}
|
|
||||||
private async Task PumpAsync(NetworkStream source, NetworkStream destination)
|
|
||||||
{
|
|
||||||
var buffer = new byte[16 * 1024];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var length = await source.ReadAsync(buffer, _stop.Token);
|
|
||||||
if (length == 0) return;
|
|
||||||
if (Volatile.Read(ref _pause) is { } paused) await paused.Task.WaitAsync(_stop.Token);
|
|
||||||
await destination.WriteAsync(buffer.AsMemory(0, length), _stop.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
|
|
||||||
catch (IOException) when (_stop.IsCancellationRequested) { }
|
|
||||||
}
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
Resume(); _stop.Cancel(); _listener.Stop();
|
|
||||||
foreach (var socket in _sockets) socket.Dispose();
|
|
||||||
try { await _accept; await Task.WhenAll(_pumps); }
|
|
||||||
finally { _stop.Dispose(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-242
@@ -1,242 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Net;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Opc.Ua;
|
|
||||||
using Opc.Ua.Client;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using Opc.Ua.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
||||||
|
|
||||||
/// <summary>Real, mutually trusted channels: no auto-accept callbacks or insecure fallback.</summary>
|
|
||||||
public sealed class Sdk2TrustedChannelTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign, MessageSecurityMode.Sign)]
|
|
||||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, MessageSecurityMode.SignAndEncrypt)]
|
|
||||||
public async Task Trusted_key_retains_narrow_authority_and_revocation_reaches_its_open_session(
|
|
||||||
OpcUaSecurityProfile profile, MessageSecurityMode mode)
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
|
||||||
using var session = await rig.ConnectAsync("contract-key", ct);
|
|
||||||
session.Endpoint.SecurityMode.ShouldBe(mode);
|
|
||||||
(await session.ReadValueAsync(rig.Node(session), ct)).WrappedValue.GetInt32().ShouldBe(7);
|
|
||||||
(await rig.WriteAsync(session, 42, ct)).ShouldBe(StatusCodes.Good);
|
|
||||||
rig.Writes.ShouldBe(1, "a narrow grant with no application-wide roles must permit the write");
|
|
||||||
var observedPeer = rig.Peers.ShouldHaveSingleItem();
|
|
||||||
observedPeer.ShouldNotBeNull();
|
|
||||||
IPAddress.IsLoopback(observedPeer.Address).ShouldBeTrue();
|
|
||||||
observedPeer.Port.ShouldBe(0, "the public SDK evidence supplies an IP address, not an invented client port");
|
|
||||||
var originalSessionId = session.SessionId;
|
|
||||||
await session.ReconnectAsync(null, null, ct);
|
|
||||||
session.SessionId.ShouldBe(originalSessionId, "the test must reactivate the existing session on a new channel");
|
|
||||||
rig.Peers.Count.ShouldBe(2);
|
|
||||||
rig.Peers.Last().ShouldNotBeNull().Address.ShouldBe(observedPeer.Address);
|
|
||||||
(await session.ReadValueAsync(rig.Node(session), ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
|
||||||
var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity
|
|
||||||
.ShouldBeOfType<RoleCarryingUserIdentity>();
|
|
||||||
identity.Principal.ShouldBe(TrustedRig.Principal);
|
|
||||||
identity.ApiKeyId.ShouldBe(TrustedRig.KeyId);
|
|
||||||
identity.Roles.ShouldBeEmpty();
|
|
||||||
|
|
||||||
rig.Revoke();
|
|
||||||
(await rig.WriteAsync(session, 99, ct)).ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
||||||
rig.Writes.ShouldBe(1, "revocation must be checked before a cached narrow grant or dispatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
|
|
||||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
|
|
||||||
public async Task Trusted_channel_does_not_turn_invalid_credentials_into_another_identity(OpcUaSecurityProfile profile)
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
|
||||||
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("invalid", ct));
|
|
||||||
error.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
|
||||||
rig.Writes.ShouldBe(0);
|
|
||||||
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldAllBe(s => !s.Activated);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task An_untrusted_client_is_rejected_before_application_authentication()
|
|
||||||
{
|
|
||||||
var ct = TestContext.Current.CancellationToken;
|
|
||||||
await using var rig = await TrustedRig.StartAsync(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, ct);
|
|
||||||
var before = rig.AuthCalls;
|
|
||||||
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync("contract-key", ct, untrustedClient: true));
|
|
||||||
new[] { StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed, StatusCodes.BadSecureChannelClosed }
|
|
||||||
.ShouldContain(error.StatusCode);
|
|
||||||
rig.AuthCalls.ShouldBe(before);
|
|
||||||
rig.Writes.ShouldBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class TrustedRig : IAsyncDisposable, IOpcUaUserAuthenticator, IOpcUaNodeWriteGateway
|
|
||||||
{
|
|
||||||
internal const string Principal = "trusted-contract";
|
|
||||||
internal const string KeyId = "k-trusted-contract";
|
|
||||||
private const string Cluster = "SDK2";
|
|
||||||
private const string PathId = "Plant/Device/Value";
|
|
||||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otua-trusted-{Guid.NewGuid():N}");
|
|
||||||
private readonly Dictionary<ApplicationConfiguration, ApplicationInstance> _applications = [];
|
|
||||||
private readonly DataPlaneAuthorityState _authority = new();
|
|
||||||
private readonly OpcUaApplicationHostOptions _options;
|
|
||||||
private OpcUaApplicationHost _host = null!;
|
|
||||||
private int _port;
|
|
||||||
private int _writes;
|
|
||||||
private int _authCalls;
|
|
||||||
internal ConcurrentQueue<IPEndPoint?> Peers { get; } = new();
|
|
||||||
internal OtOpcUaSdkServer Server { get; private set; } = null!;
|
|
||||||
internal int Writes => Volatile.Read(ref _writes);
|
|
||||||
internal int AuthCalls => Volatile.Read(ref _authCalls);
|
|
||||||
private string ServerPki => System.IO.Path.Combine(_root, "server");
|
|
||||||
private string ClientPki => System.IO.Path.Combine(_root, "client");
|
|
||||||
|
|
||||||
private TrustedRig(OpcUaSecurityProfile profile)
|
|
||||||
{
|
|
||||||
_options = new OpcUaApplicationHostOptions
|
|
||||||
{
|
|
||||||
ApplicationName = "Sdk2TrustedContract", ApplicationUri = $"urn:Sdk2TrustedContract:{Guid.NewGuid():N}",
|
|
||||||
PublicHostname = "localhost", PkiStoreRoot = ServerPki, AllowAnonymous = false,
|
|
||||||
AutoAcceptUntrustedClientCertificates = false, EnabledSecurityProfiles = [profile],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static async Task<TrustedRig> StartAsync(OpcUaSecurityProfile profile, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var rig = new TrustedRig(profile);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Provision both certificates, then reload the managers with explicit trusted-peer stores.
|
|
||||||
await rig.BootAsync(ct);
|
|
||||||
await rig._host.DisposeAsync();
|
|
||||||
var client = await rig.ConfigurationAsync(rig.ClientPki, ct);
|
|
||||||
CopyCertificates(rig.ServerPki, rig.ClientPki);
|
|
||||||
CopyCertificates(rig.ClientPki, rig.ServerPki);
|
|
||||||
await rig.ReleaseConfigurationAsync(client);
|
|
||||||
await rig.BootAsync(ct);
|
|
||||||
rig.InstallAuthority(enabled: true);
|
|
||||||
var nm = rig.Server.NodeManager!;
|
|
||||||
nm.DataPlaneAuthority = rig._authority;
|
|
||||||
nm.Authorizer = new DataPlaneAuthorizer(rig._authority, new TriePermissionEvaluator(rig._authority.Tries),
|
|
||||||
true, NullLogger.Instance, TimeProvider.System);
|
|
||||||
nm.NodeWriteGateway = rig;
|
|
||||||
var sink = new SdkAddressSpaceSink(nm);
|
|
||||||
sink.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
|
|
||||||
sink.EnsureFolder("Plant/Device", "Plant", "Device", AddressSpaceRealm.Raw);
|
|
||||||
sink.EnsureVariable(PathId, "Plant/Device", "Value", "Int32", true, AddressSpaceRealm.Raw);
|
|
||||||
sink.WriteValue(PathId, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
||||||
return rig;
|
|
||||||
}
|
|
||||||
catch { await rig.DisposeAsync(); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task BootAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
(_host, Server) = await OpcUaHostStart.StartAsync(port =>
|
|
||||||
{
|
|
||||||
_port = port; _options.OpcUaPort = port;
|
|
||||||
return new OpcUaApplicationHost(_options, NullLogger<OpcUaApplicationHost>.Instance, this);
|
|
||||||
}, () => new OtOpcUaSdkServer(), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CopyCertificates(string from, string to)
|
|
||||||
{
|
|
||||||
var destination = System.IO.Path.Combine(to, "trusted", "certs");
|
|
||||||
Directory.CreateDirectory(destination);
|
|
||||||
foreach (var file in Directory.GetFiles(System.IO.Path.Combine(from, "own", "certs"), "*.der"))
|
|
||||||
File.Copy(file, System.IO.Path.Combine(destination, System.IO.Path.GetFileName(file)), overwrite: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ApplicationConfiguration> ConfigurationAsync(string pki, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
var security = TestClientSecurity.Build(pki);
|
|
||||||
security.AutoAcceptUntrustedCertificates = false;
|
|
||||||
security.ApplicationCertificate!.SubjectName = "CN=Sdk2TrustedClient";
|
|
||||||
var config = new ApplicationConfiguration(telemetry)
|
|
||||||
{
|
|
||||||
ApplicationName = "Sdk2TrustedClient", ApplicationUri = "urn:localhost:Sdk2TrustedClient",
|
|
||||||
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
|
|
||||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
||||||
};
|
|
||||||
var app = new ApplicationInstance(telemetry)
|
|
||||||
{ ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client, ApplicationConfiguration = config };
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
||||||
await app.CheckApplicationInstanceCertificatesAsync(true, lifeTimeInMonths: 12, ct);
|
|
||||||
_applications.Add(config, app);
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
catch { await app.DisposeAsync(); throw; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReleaseConfigurationAsync(ApplicationConfiguration config)
|
|
||||||
{
|
|
||||||
if (_applications.Remove(config, out var app)) await app.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false)
|
|
||||||
{
|
|
||||||
var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki;
|
|
||||||
var config = await ConfigurationAsync(pki, ct);
|
|
||||||
if (untrustedClient)
|
|
||||||
{
|
|
||||||
await ReleaseConfigurationAsync(config);
|
|
||||||
CopyCertificates(ServerPki, pki);
|
|
||||||
config = await ConfigurationAsync(pki, ct);
|
|
||||||
}
|
|
||||||
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeFalse();
|
|
||||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
|
||||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://localhost:{_port}/OtOpcUa", true, telemetry, ct)
|
|
||||||
?? throw new InvalidOperationException("No secure endpoint.");
|
|
||||||
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
|
|
||||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true,
|
|
||||||
"Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal NodeId Node(ISession session) => new(PathId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
||||||
internal async Task<StatusCode> WriteAsync(ISession session, int value, CancellationToken ct) =>
|
|
||||||
(await session.WriteAsync(null, [new WriteValue { NodeId = Node(session), AttributeId = Attributes.Value,
|
|
||||||
Value = new DataValue(Variant.From(value)) }], ct)).Results[0];
|
|
||||||
internal void Revoke() => InstallAuthority(enabled: false);
|
|
||||||
private void InstallAuthority(bool enabled) => _authority.Apply(Cluster, enabled ? "allowed" : "revoked",
|
|
||||||
[new ApiKey { ApiKeyId = KeyId, Name = KeyId, Principal = Principal, Enabled = enabled,
|
|
||||||
SecretHash = "hash", CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
|
|
||||||
[new NodeAcl { NodeAclId = "narrow", ClusterId = Cluster, Principal = Principal,
|
|
||||||
ScopeKind = NodeAclScopeKind.RawDevice, ScopeId = "device", PermissionFlags =
|
|
||||||
NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe | NodePermissions.WriteOperate }],
|
|
||||||
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
|
||||||
{ [new(NodeAclScopeKind.RawDevice, "device")] = [new([NodeAclRealms.Raw, "Plant", "Device"])] });
|
|
||||||
|
|
||||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, IPEndPoint? peer, CancellationToken ct)
|
|
||||||
{
|
|
||||||
Peers.Enqueue(peer is null ? null : new IPEndPoint(peer.Address, peer.Port));
|
|
||||||
return AuthenticateUserNameAsync(username, password, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
|
||||||
{
|
|
||||||
Interlocked.Increment(ref _authCalls);
|
|
||||||
return Task.FromResult(username == "apikey" && password == "contract-key"
|
|
||||||
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential."));
|
|
||||||
}
|
|
||||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
|
||||||
{ Interlocked.Increment(ref _writes); return Task.FromResult(new NodeWriteOutcome(true, null)); }
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
if (_host is not null) await _host.DisposeAsync();
|
|
||||||
foreach (var app in _applications.Values) await app.DisposeAsync();
|
|
||||||
_applications.Clear();
|
|
||||||
if (Directory.Exists(_root)) Directory.Delete(_root, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,8 +46,6 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
|||||||
|
|
||||||
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
public OtOpcUaNodeManager Nm => _server.NodeManager!;
|
||||||
|
|
||||||
internal OtOpcUaSdkServer Server => _server;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The roles the <c>apikey</c> session activates with (read at each activation). Defaults to the two
|
/// The roles the <c>apikey</c> session activates with (read at each activation). Defaults to the two
|
||||||
/// server-wide roles; a flag-off test sets it from <c>ApiKeyInterimRoleMapper.Map</c> over its own grants, as
|
/// server-wide roles; a flag-off test sets it from <c>ApiKeyInterimRoleMapper.Map</c> over its own grants, as
|
||||||
|
|||||||
@@ -41,31 +41,6 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
|||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
|
$"otopcua-alarm-src-fields-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("OffNormalAlarm", AddressSpaceRealm.Raw)]
|
|
||||||
[InlineData("DiscreteAlarm", AddressSpaceRealm.Raw)]
|
|
||||||
[InlineData("AlarmCondition", AddressSpaceRealm.Raw)]
|
|
||||||
[InlineData("OffNormalAlarm", AddressSpaceRealm.Uns)]
|
|
||||||
[InlineData("DiscreteAlarm", AddressSpaceRealm.Uns)]
|
|
||||||
[InlineData("AlarmCondition", AddressSpaceRealm.Uns)]
|
|
||||||
public async Task Created_alarm_retains_the_parent_component_reference(string type, AddressSpaceRealm realm)
|
|
||||||
{
|
|
||||||
await using var host = await BootAsync();
|
|
||||||
var nm = host.Nm;
|
|
||||||
nm.EnsureFolder("browse-parent", null, "Parent", realm);
|
|
||||||
nm.MaterialiseAlarmCondition("browse-alarm", "browse-parent", "Alarm", type, 500, realm, isNative: realm == AddressSpaceRealm.Raw);
|
|
||||||
var condition = nm.TryGetAlarmCondition("browse-alarm", realm)!;
|
|
||||||
condition.ReferenceTypeId.ShouldBe(ReferenceTypeIds.HasComponent);
|
|
||||||
condition.Parent!.NodeId.ShouldBe(new NodeId("browse-parent", nm.NamespaceIndexForRealm(realm)));
|
|
||||||
condition.TypeDefinitionId.ShouldBe(type switch
|
|
||||||
{
|
|
||||||
"OffNormalAlarm" => ObjectTypeIds.OffNormalAlarmType,
|
|
||||||
"DiscreteAlarm" => ObjectTypeIds.DiscreteAlarmType,
|
|
||||||
_ => ObjectTypeIds.AlarmConditionType,
|
|
||||||
});
|
|
||||||
condition.NodeClass.ShouldBe(NodeClass.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
|
/// <summary>A NATIVE condition (Raw realm, ConditionId = RawPath) carries all three identity fields:
|
||||||
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
|
/// EventType = its type definition, SourceNode = its own NodeId, SourceName = the RawPath. The leaf name
|
||||||
/// remains on ConditionName so the short name is still available to clients.</summary>
|
/// remains on ConditionName so the short name is still available to clients.</summary>
|
||||||
|
|||||||
+3
-2
@@ -28,6 +28,7 @@ using LocalizedText = Opc.Ua.LocalizedText;
|
|||||||
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using UaNodeId = Opc.Ua.NodeId;
|
using UaNodeId = Opc.Ua.NodeId;
|
||||||
using Variant = Opc.Ua.Variant;
|
using Variant = Opc.Ua.Variant;
|
||||||
|
using VariantCollection = Opc.Ua.VariantCollection;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
@@ -504,9 +505,9 @@ public sealed class NativeAlarmRestatementEndToEndTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An upstream A&C condition event, in the driver's select-clause order.</summary>
|
/// <summary>An upstream A&C condition event, in the driver's select-clause order.</summary>
|
||||||
private static Variant[] ConditionFields(bool active, bool acked) =>
|
private static VariantCollection ConditionFields(bool active, bool acked) =>
|
||||||
[
|
[
|
||||||
new Variant(Opc.Ua.ByteString.From(new byte[] { 1 })), // EventId
|
new Variant(new byte[] { 1 }), // EventId
|
||||||
new Variant(new UaNodeId("TripAlarmType", 3)), // EventType
|
new Variant(new UaNodeId("TripAlarmType", 3)), // EventType
|
||||||
new Variant(UaNodeId.Parse(UpstreamSource)), // SourceNode
|
new Variant(UaNodeId.Parse(UpstreamSource)), // SourceNode
|
||||||
new Variant(new LocalizedText("tripped")), // Message
|
new Variant(new LocalizedText("tripped")), // Message
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua.Server;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||||
@@ -13,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task 60 / #81 — verifies the full path from cluster redundancy state to OPC UA
|
/// Task 60 / #81 — verifies the full path from cluster redundancy state to OPC UA
|
||||||
/// <c>Server.ServiceLevel</c> visible on the wire. Boots a real <see cref="OtOpcUaSdkServer"/>,
|
/// <c>Server.ServiceLevel</c> visible on the wire. Boots a real <see cref="StandardServer"/>,
|
||||||
/// wires <see cref="SdkServiceLevelPublisher"/> into a <see cref="DeferredServiceLevelPublisher"/>
|
/// wires <see cref="SdkServiceLevelPublisher"/> into a <see cref="DeferredServiceLevelPublisher"/>
|
||||||
/// (the production binding pattern), spawns <see cref="OpcUaPublishActor"/> against the
|
/// (the production binding pattern), spawns <see cref="OpcUaPublishActor"/> against the
|
||||||
/// deferred publisher, and sends a <see cref="RedundancyStateChanged"/> snapshot. Asserts
|
/// deferred publisher, and sends a <see cref="RedundancyStateChanged"/> snapshot. Asserts
|
||||||
@@ -34,7 +35,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
|
|||||||
port => new OpcUaApplicationHost(
|
port => new OpcUaApplicationHost(
|
||||||
BuildOptions("PrimaryLeader", pkiRoot, port),
|
BuildOptions("PrimaryLeader", pkiRoot, port),
|
||||||
NullLogger<OpcUaApplicationHost>.Instance),
|
NullLogger<OpcUaApplicationHost>.Instance),
|
||||||
() => new OtOpcUaSdkServer(), Ct);
|
() => new StandardServer(), Ct);
|
||||||
await using var host = started.Host;
|
await using var host = started.Host;
|
||||||
var server = started.Server;
|
var server = started.Server;
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
|
|||||||
CorrelationId: CorrelationId.NewId()));
|
CorrelationId: CorrelationId.NewId()));
|
||||||
|
|
||||||
AwaitAssertion(() =>
|
AwaitAssertion(() =>
|
||||||
server.CurrentInstance.ServerObject.ServiceLevel.ShouldNotBeNull().Value.ShouldBe((byte)240));
|
server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -76,7 +77,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
|
|||||||
port => new OpcUaApplicationHost(
|
port => new OpcUaApplicationHost(
|
||||||
BuildOptions("Secondary", pkiRoot, port),
|
BuildOptions("Secondary", pkiRoot, port),
|
||||||
NullLogger<OpcUaApplicationHost>.Instance),
|
NullLogger<OpcUaApplicationHost>.Instance),
|
||||||
() => new OtOpcUaSdkServer(), Ct);
|
() => new StandardServer(), Ct);
|
||||||
await using var host = started.Host;
|
await using var host = started.Host;
|
||||||
var server = started.Server;
|
var server = started.Server;
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
|
|||||||
CorrelationId: CorrelationId.NewId()));
|
CorrelationId: CorrelationId.NewId()));
|
||||||
|
|
||||||
AwaitAssertion(() =>
|
AwaitAssertion(() =>
|
||||||
server.CurrentInstance.ServerObject.ServiceLevel.ShouldNotBeNull().Value.ShouldBe((byte)100));
|
server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)100));
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user