3fff439b11
v2-ci / unit-tests (push) Has been cancelled
The SDK starts Server.ServiceLevel at 255 (full service), and OpcUaPublishActor publishes nothing until the node's first redundancy snapshot arrives, so a restarting pair node advertised 255 for the ~10-13 s it took to join, out-ranking the real Primary (250) for any client that picks the highest ServiceLevel. OtOpcUaSdkServer now writes StartupServiceLevel = 0 in OnNodeManagerStartedAsync: after the SDK's CreateServerObjectAsync wrote 255 and before the server goes Running, so no session can ever read 255. 0 is the design's existing Down / Detached tier (ServiceLevelCalculator returns 0 for a member that is not Up/Joining, and a node with no snapshot has no role yet). It is below the SDK 2 session-headroom floor (200), so it stands through session churn before the #884 claim, and the claim's bind replays any level already requested. Single-node deployments are unaffected beyond the window: the first snapshot publishes 250 as before. New Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published: reads 0 before the bind, 0 after a bind with nothing to replay, then 250 once published. Without the startup write it reads 255 on the first session (mutation-checked). docs/Redundancy.md updated for #884 and #885. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
227 lines
12 KiB
C#
227 lines
12 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>Local loopback evidence that late binding replaces the SDK default on the wire.</summary>
|
|
public sealed class ServiceLevelBindingReplayTests
|
|
{
|
|
// Since #885 the server starts at 0, so the 0 case no longer distinguishes "replayed" from "not replayed" — it pins
|
|
// that a requested 0 still reads 0. The 100 case is the replay evidence.
|
|
[Theory]
|
|
[InlineData((byte)100)]
|
|
[InlineData((byte)0)]
|
|
public async Task Late_binding_replays_latest_request_to_standard_ServiceLevel_read(byte latest)
|
|
{
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
deadline.CancelAfter(TimeSpan.FromSeconds(15));
|
|
var ct = deadline.Token;
|
|
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-replay-{Guid.NewGuid():N}");
|
|
var clientPki = TestClientSecurity.AllocatePkiRoot();
|
|
var port = 0;
|
|
try
|
|
{
|
|
var deferred = new DeferredServiceLevelPublisher();
|
|
deferred.Publish(240);
|
|
deferred.Publish(latest);
|
|
var started = await OpcUaHostStart.StartAsync(p =>
|
|
{
|
|
port = p;
|
|
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.Replay",
|
|
ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}",
|
|
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
|
|
}, NullLogger<OpcUaApplicationHost>.Instance);
|
|
}, () => new OtOpcUaSdkServer(), ct);
|
|
await using var host = started.Host;
|
|
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
|
|
NullLogger<SdkServiceLevelPublisher>.Instance));
|
|
var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
|
{
|
|
ApplicationName = "OtOpcUa.ReplayClient",
|
|
ApplicationUri = $"urn:OtOpcUa.ReplayClient:{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = TestClientSecurity.Build(clientPki),
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
|
|
};
|
|
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(config,
|
|
$"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
|
|
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
|
config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)),
|
|
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay",
|
|
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
|
|
preferredLocales: default, ct: ct);
|
|
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
|
|
read.StatusCode.ShouldBe(StatusCodes.Good);
|
|
read.ClrValue()!.ShouldBe(latest);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
|
|
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#801: SDK 2.0 recomputes Server.ServiceLevel from session headroom on every session create/close
|
|
/// and overwrites any value >= 200 unless a provider has claimed it. On the docker-dev pair that turned the
|
|
/// Primary's 250 and the Secondary's 240 into 255 on both nodes as soon as a client connected. A level published
|
|
/// BEFORE a session opens and one published while a session is open must both survive later sessions opening
|
|
/// and closing.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData((byte)250)]
|
|
[InlineData((byte)240)]
|
|
[InlineData((byte)200)]
|
|
public async Task Published_level_survives_later_sessions(byte level)
|
|
{
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
deadline.CancelAfter(TimeSpan.FromSeconds(30));
|
|
var ct = deadline.Token;
|
|
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-claim-{Guid.NewGuid():N}");
|
|
var clientPki = TestClientSecurity.AllocatePkiRoot();
|
|
var port = 0;
|
|
try
|
|
{
|
|
var started = await OpcUaHostStart.StartAsync(p =>
|
|
{
|
|
port = p;
|
|
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.Claim",
|
|
ApplicationUri = $"urn:OtOpcUa.Claim:{Path.GetFileName(pki)}",
|
|
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
|
|
}, NullLogger<OpcUaApplicationHost>.Instance);
|
|
}, () => new OtOpcUaSdkServer(), ct);
|
|
await using var host = started.Host;
|
|
var publisher = new SdkServiceLevelPublisher(started.Server.CurrentInstance,
|
|
NullLogger<SdkServiceLevelPublisher>.Instance);
|
|
publisher.Publish(level);
|
|
var url = $"opc.tcp://localhost:{port}/OtOpcUa";
|
|
|
|
// SessionCreated: the first session opening.
|
|
using (var first = await OpenAsync(url, clientPki, ct))
|
|
{
|
|
(await ReadServiceLevelAsync(first, ct)).ShouldBe(level, "the first session opening must not displace the published level");
|
|
// An explicit CloseSession so the server raises SessionClosing (Dispose alone may leave the session to time out).
|
|
await first.CloseAsync(5_000, true, ct);
|
|
}
|
|
|
|
using var second = await OpenAsync(url, clientPki, ct);
|
|
(await ReadServiceLevelAsync(second, ct)).ShouldBe(level, "a session closing then another opening must not displace it");
|
|
|
|
// A level published while a session is open (230 keeps every case inside the SDK's >= 200 overwrite band)
|
|
// must survive another session opening AND, read on the surviving session, that session closing on its own.
|
|
const byte later = 230;
|
|
publisher.Publish(later);
|
|
using (var third = await OpenAsync(url, clientPki, ct))
|
|
{
|
|
(await ReadServiceLevelAsync(third, ct)).ShouldBe(later, "a level published while a session is open must survive the next one");
|
|
await third.CloseAsync(5_000, true, ct);
|
|
}
|
|
(await ReadServiceLevelAsync(second, ct)).ShouldBe(later, "a session closing on its own must not displace it");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
|
|
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#885: a node that has not yet received its first redundancy snapshot has requested no level. The SDK starts
|
|
/// <c>Server.ServiceLevel</c> at 255, so a restarting pair node advertised full service, out-ranking the real Primary
|
|
/// (250), for the ~10 s it took to join. It must read <see cref="OtOpcUaSdkServer.StartupServiceLevel"/> (0) from its
|
|
/// first session, through the bind with nothing to replay and across session churn, and still settle to the level
|
|
/// the redundancy layer publishes once it has one (a single-node deployment's 250).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published()
|
|
{
|
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
|
deadline.CancelAfter(TimeSpan.FromSeconds(30));
|
|
var ct = deadline.Token;
|
|
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-startup-{Guid.NewGuid():N}");
|
|
var clientPki = TestClientSecurity.AllocatePkiRoot();
|
|
var port = 0;
|
|
try
|
|
{
|
|
var started = await OpcUaHostStart.StartAsync(p =>
|
|
{
|
|
port = p;
|
|
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.Startup",
|
|
ApplicationUri = $"urn:OtOpcUa.Startup:{Path.GetFileName(pki)}",
|
|
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
|
|
}, NullLogger<OpcUaApplicationHost>.Instance);
|
|
}, () => new OtOpcUaSdkServer(), ct);
|
|
await using var host = started.Host;
|
|
var url = $"opc.tcp://localhost:{port}/OtOpcUa";
|
|
|
|
// Before the host binds a publisher: the SDK-start window.
|
|
using (var early = await OpenAsync(url, clientPki, ct))
|
|
{
|
|
(await ReadServiceLevelAsync(early, ct)).ShouldBe(OtOpcUaSdkServer.StartupServiceLevel,
|
|
"an endpoint that has not been told its redundancy level must not advertise the SDK's 255");
|
|
await early.CloseAsync(5_000, true, ct);
|
|
}
|
|
|
|
// The bind with nothing to replay (no snapshot yet), then session churn under the claim.
|
|
var deferred = new DeferredServiceLevelPublisher();
|
|
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
|
|
NullLogger<SdkServiceLevelPublisher>.Instance));
|
|
using var session = await OpenAsync(url, clientPki, ct);
|
|
(await ReadServiceLevelAsync(session, ct)).ShouldBe(OtOpcUaSdkServer.StartupServiceLevel,
|
|
"binding with no requested level must leave the startup level standing");
|
|
|
|
// The first snapshot arrives: a single-node deployment settles to its steady Primary value.
|
|
deferred.Publish(250);
|
|
using (var later = await OpenAsync(url, clientPki, ct))
|
|
await later.CloseAsync(5_000, true, ct);
|
|
(await ReadServiceLevelAsync(session, ct)).ShouldBe((byte)250, "the published level must replace the startup level");
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
|
|
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static async Task<Opc.Ua.Client.ISession> OpenAsync(string url, string clientPki, CancellationToken ct)
|
|
{
|
|
var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
|
{
|
|
ApplicationName = "OtOpcUa.ClaimClient",
|
|
ApplicationUri = $"urn:OtOpcUa.ClaimClient:{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = TestClientSecurity.Build(clientPki),
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
|
|
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
|
|
};
|
|
await config.ValidateAsync(ApplicationType.Client, ct);
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, url, false, DefaultTelemetry.Create(_ => { }), ct);
|
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
|
config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)),
|
|
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelClaim",
|
|
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
|
|
preferredLocales: default, ct: ct);
|
|
}
|
|
|
|
private static async Task<byte> ReadServiceLevelAsync(Opc.Ua.Client.ISession session, CancellationToken ct)
|
|
{
|
|
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
|
|
read.StatusCode.ShouldBe(StatusCodes.Good);
|
|
return (byte)read.ClrValue()!;
|
|
}
|
|
}
|