Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b463894f | |||
| 4ea379fa92 | |||
| 2798021623 | |||
| 18c22cf7eb | |||
| 9f351ee476 | |||
| 3a11087b37 | |||
| 8a1028c4e2 | |||
| d31560cd36 | |||
| a8e1082d69 | |||
| 7ca0782b2a |
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,226 @@
|
||||
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(); }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,93 @@
|
||||
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 }));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user