Closes Stream C per docs/v2/implementation/phase-6-1-resilience-and-observability.md. Core.Observability (new namespace): - DriverHealthReport — pure-function aggregation over DriverHealthSnapshot list. Empty fleet = Healthy. Any Faulted = Faulted. Any Unknown/Initializing (no Faulted) = NotReady. Any Degraded or Reconnecting (no Faulted, no NotReady) = Degraded. Else Healthy. HttpStatus(verdict) maps to the Stream C.1 state matrix: Healthy/Degraded → 200, NotReady/Faulted → 503. - LogContextEnricher — Serilog LogContext wrapper. Push(id, type, capability, correlationId) returns an IDisposable scope; inner log calls carry DriverInstanceId / DriverType / CapabilityName / CorrelationId structured properties automatically. NewCorrelationId = 12-hex-char GUID slice for cases where no OPC UA RequestHeader.RequestHandle is in flight. CapabilityInvoker — now threads LogContextEnricher around every ExecuteAsync / ExecuteWriteAsync call site. OtOpcUaServer passes driver.DriverType through so logs correlate to the driver type too. Every capability call emits structured fields per the Stream C.4 compliance check. Server.Observability: - HealthEndpointsHost — standalone HttpListener on http://localhost:4841/ (loopback avoids Windows URL-ACL elevation; remote probing via reverse proxy or explicit netsh urlacl grant). Routes: /healthz → 200 when (configDbReachable OR usingStaleConfig); 503 otherwise. Body: status, uptimeSeconds, configDbReachable, usingStaleConfig. /readyz → DriverHealthReport.Aggregate + HttpStatus mapping. Body: verdict, drivers[], degradedDrivers[], uptimeSeconds. anything else → 404. Disposal cooperative with the HttpListener shutdown. - OpcUaApplicationHost starts the health host after the OPC UA server comes up and disposes it on shutdown. New OpcUaServerOptions knobs: HealthEndpointsEnabled (default true), HealthEndpointsPrefix (default http://localhost:4841/). Program.cs: - Serilog pipeline adds Enrich.FromLogContext + opt-in JSON file sink via `Serilog:WriteJson = true` appsetting. Uses Serilog.Formatting.Compact's CompactJsonFormatter (one JSON object per line — SIEMs like Splunk, Datadog, Graylog ingest without a regex parser). Server.Tests: - Existing 3 OpcUaApplicationHost integration tests now set HealthEndpointsEnabled=false to avoid port :4841 collisions under parallel execution. - New HealthEndpointsHostTests (9): /healthz healthy empty fleet; stale-config returns 200 with flag; unreachable+no-cache returns 503; /readyz empty/ Healthy/Faulted/Degraded/Initializing drivers return correct status and bodies; unknown path → 404. Uses ephemeral ports via Interlocked counter. Core.Tests: - DriverHealthReportTests (8): empty fleet, all-healthy, any-Faulted trumps, any-NotReady without Faulted, Degraded without Faulted/NotReady, HttpStatus per-verdict theory. - LogContextEnricherTests (8): all 4 properties attach; scope disposes cleanly; NewCorrelationId shape; null/whitespace driverInstanceId throws. - CapabilityInvokerEnrichmentTests (2): inner logs carry structured properties; no context leak outside the call site. Full solution dotnet test: 1016 passing (baseline 906, +110 for Phase 6.1 so far across Streams A+B+C). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
7.5 KiB
C#
160 lines
7.5 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
[Trait("Category", "Integration")]
|
|
public sealed class OpcUaServerIntegrationTests : IAsyncLifetime
|
|
{
|
|
// Use a non-default port + per-test-run PKI root to avoid colliding with anything else
|
|
// running on the box (a live v1 Host or a developer's previous run).
|
|
private static readonly int Port = 48400 + Random.Shared.Next(0, 99);
|
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaTest";
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-test-{Guid.NewGuid():N}");
|
|
|
|
private DriverHost _driverHost = null!;
|
|
private OpcUaApplicationHost _server = null!;
|
|
private FakeDriver _driver = null!;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
_driverHost = new DriverHost();
|
|
_driver = new FakeDriver();
|
|
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
|
|
|
var options = new OpcUaServerOptions
|
|
{
|
|
EndpointUrl = _endpoint,
|
|
ApplicationName = "OtOpcUaTest",
|
|
ApplicationUri = "urn:OtOpcUa:Server:Test",
|
|
PkiStoreRoot = _pkiRoot,
|
|
AutoAcceptUntrustedClientCertificates = true, HealthEndpointsEnabled = false,
|
|
};
|
|
|
|
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
|
await _server.StartAsync(CancellationToken.None);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _server.DisposeAsync();
|
|
await _driverHost.DisposeAsync();
|
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Client_can_connect_and_browse_driver_subtree()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
|
|
// Browse the driver subtree registered under ObjectsFolder. FakeDriver registers one
|
|
// folder ("TestFolder") with one variable ("Var1"), so we expect to see our driver's
|
|
// root folder plus standard UA children.
|
|
var rootRef = new NodeId("fake", (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake"));
|
|
session.Browse(null, null, rootRef, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
|
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var references);
|
|
|
|
references.Count.ShouldBeGreaterThan(0);
|
|
references.ShouldContain(r => r.BrowseName.Name == "TestFolder");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Client_can_read_a_driver_variable_through_the_node_manager()
|
|
{
|
|
using var session = await OpenSessionAsync();
|
|
|
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake");
|
|
var varNodeId = new NodeId("TestFolder.Var1", nsIndex);
|
|
|
|
var dv = session.ReadValue(varNodeId);
|
|
dv.ShouldNotBeNull();
|
|
// FakeDriver.ReadAsync returns 42 as the value.
|
|
dv.Value.ShouldBe(42);
|
|
}
|
|
|
|
private async Task<ISession> OpenSessionAsync()
|
|
{
|
|
var cfg = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUaTestClient",
|
|
ApplicationUri = "urn:OtOpcUa:TestClient",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
|
SubjectName = "CN=OtOpcUaTestClient",
|
|
},
|
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
|
AutoAcceptUntrustedCertificates = true,
|
|
AddAppCertToTrustedStore = true,
|
|
},
|
|
TransportConfigurations = new TransportConfigurationCollection(),
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
|
};
|
|
await cfg.Validate(ApplicationType.Client);
|
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
|
|
|
// Let the client fetch the live endpoint description from the running server so the
|
|
// UserTokenPolicy it signs with matches what the server actually advertised (including
|
|
// the PolicyId = "Anonymous" the server sets).
|
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
|
|
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaTestClientSession", 60000,
|
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimum driver that implements enough of IDriver + ITagDiscovery + IReadable to drive
|
|
/// the integration test. Returns a single folder with one variable that reads as 42.
|
|
/// </summary>
|
|
private sealed class FakeDriver : IDriver, ITagDiscovery, IReadable
|
|
{
|
|
public string DriverInstanceId => "fake";
|
|
public string DriverType => "Fake";
|
|
|
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
|
public long GetMemoryFootprint() => 0;
|
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
|
{
|
|
var folder = builder.Folder("TestFolder", "TestFolder");
|
|
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
|
|
"TestFolder.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
IReadOnlyList<DataValueSnapshot> result =
|
|
fullReferences.Select(_ => new DataValueSnapshot(42, 0u, now, now)).ToArray();
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
}
|