8 Commits

Author SHA1 Message Date
Joseph Doherty 05a0596fb1 feat(host): F9b RoslynScriptedAlarmEvaluator + #107 close engine DI
v2-ci / build (push) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
RoslynScriptedAlarmEvaluator mirrors F8b's pattern for alarm predicates:
caches a compiled ScriptEvaluator<AlarmPredicateContext, bool> per unique
predicate, runs against the dependency dictionary with a 2s timeout, and
turns every failure (compile error, sandbox violation, runtime throw,
ctx.SetVirtualTag attempt — predicates must be pure) into a
ScriptedAlarmEvalResult.Failure. ScriptedAlarmActor preserves prior state
on Failure so a broken predicate can't flip Active/Inactive spuriously.

Program.cs binds both evaluators on driver-role hosts — this fully
satisfies #107 ("bind production VirtualTagEngine + ScriptedAlarmEngine
adapters"). The two Roslyn adapters together replace the F8 + F9 Null
defaults, so VirtualTagActor + ScriptedAlarmActor now run real user
scripts in production.

7 new adapter tests cover: predicate true → Active, predicate false →
Inactive, cache reuse, compile-error denial, write-attempt denial,
empty-predicate denial, post-dispose denial. Host.IntegrationTests now
17/17 green.

Closes #80 + #107. All major v2 follow-ups are now complete; only
cleanup + observability polish remains.
2026-05-26 10:58:04 -04:00
Joseph Doherty 219d10a22d feat(host): F8b RoslynVirtualTagEvaluator — production virtual-tag eval
RoslynVirtualTagEvaluator wraps Core.Scripting.ScriptEvaluator + Core
.VirtualTags.VirtualTagContext into a single-tag IVirtualTagEvaluator
adapter. Caches the compiled ScriptEvaluator per unique expression so
the second-and-onwards Evaluate is an in-process method call against the
dependency dictionary. Compile/sandbox/runtime errors all surface as
VirtualTagEvalResult.Failure rather than propagating exceptions through
the VirtualTagActor message loop.

Single-tag scope: cross-tag ctx.SetVirtualTag writes are dropped + logged
because fan-out between actors is owned by DependencyMuxActor. Cycle
detection + cascade ordering stay in Core.VirtualTags.VirtualTagEngine
where they belong (loaded fleet-wide); this adapter keeps the actor
message handler simple.

Host adds Core.Scripting + Core.VirtualTags project refs, plus a
TargetWarningsAsErrors NU1608 suppression — Microsoft.CodeAnalysis.CSharp
.Scripting 4.12.0 pins Common to 4.12.0 but ASP.NET Core transitively
brings Microsoft.CodeAnalysis.Common 5.0.0; the surface we use is stable
across the drift (verified by Core.Scripting.Tests).

Program.cs binds RoslynVirtualTagEvaluator → IVirtualTagEvaluator on
driver-role hosts, replacing the F8-default NullVirtualTagEvaluator so
VirtualTagActor evaluates real user scripts at runtime.

6 new adapter tests cover: simple expression sums, cache reuse across
calls, compile-error denial, runtime-throw denial, empty-expression
denial, post-dispose denial. Host.IntegrationTests now 10/10 green.

Closes #79. F9b + #107 next.
2026-05-26 10:55:56 -04:00
Joseph Doherty 607dc51dec feat(opcua): #85 UNS Area/Line/Equipment folder hierarchy in SDK
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Phase7Composer now carries UnsAreaProjection + UnsLineProjection lists so
the applier can materialise the full UNS topology in the OPC UA address
space. New IOpcUaAddressSpaceSink.EnsureFolder(folderNodeId, parentNodeId,
displayName) seam (no-op default, recorded in tests, forwarded by
DeferredAddressSpaceSink, implemented by SdkAddressSpaceSink). The SDK-
side OtOpcUaNodeManager gains an EnsureFolder API that creates
FolderState nodes with proper parent linkage; RebuildAddressSpace now
clears folders too so re-applies don't accumulate stale topology.

Phase7Applier.MaterialiseHierarchy walks composition.UnsAreas →
composition.UnsLines → composition.EquipmentNodes, calling EnsureFolder
with the correct parent at each level. Idempotent — calling twice with
the same composition is a no-op. OpcUaPublishActor.HandleRebuild invokes
it after Phase7Applier.Apply so OPC UA clients browsing the server now
see Area/Line/Equipment as proper folders rather than flat tag ids.

DeploymentArtifact.ParseComposition reads UnsAreas + UnsLines from the
JSON snapshot the ControlPlane emits, populating the new fields when
present.

Phase7Composer.Compose now accepts UnsAreas + UnsLines; a 3-arg overload
preserves the old signature for legacy callers + existing tests. The
Phase7CompositionResult convenience ctor likewise keeps the planner
tests working without UNS data.

3 new hierarchy tests (pure unit + boot-verify against a real
OtOpcUaSdkServer); OpcUaServer suite is 48/48 green (was 45, +3),
Runtime 74/74 unchanged.

Closes #85.
2026-05-26 10:48:56 -04:00
Joseph Doherty 9d86287d08 test(opcua): Task 60 ServiceLevel end-to-end through SDK
v2-ci / build (push) Failing after 49s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Boots a real StandardServer + OpcUaApplicationHost, wires
SdkServiceLevelPublisher into a DeferredServiceLevelPublisher (production
binding pattern), spawns OpcUaPublishActor against the deferred
publisher, sends RedundancyStateChanged snapshots, and asserts that
ServerObject.ServiceLevel.Value reflects the role-derived byte:

  Primary + RoleLeaderForDriver  → 240
  Secondary                      → 100

Together with the F13b endpoint-security tests (which already verify
ServerConfiguration.SecurityPolicies populates the three baseline
profiles), this closes Task 60's "dual-endpoint + ServiceLevel" scope.
Cross-node failover tests stay in the 2-node integration harness
(Task 59 / FailoverScenarioTests).

Runtime suite now 74 / 74 green (+2). Closes Task 60.
2026-05-26 10:40:58 -04:00
Joseph Doherty 2697af31d1 feat(opcua,host): #81 ServiceLevel SDK publisher
SdkServiceLevelPublisher writes Server.ServiceLevel through the SDK's
ServerObjectState — the standard OPC UA non-transparent-redundancy signal
clients use to pick a primary. Writes are guarded by DiagnosticsLock so
concurrent SDK diagnostics scans don't fight with our updates.

DeferredServiceLevelPublisher mirrors the DeferredAddressSpaceSink late-
binding pattern: Akka actors resolve IServiceLevelPublisher at construction,
hosted service swaps the SDK publisher in after StandardServer.Start. Host
Program.cs registers DeferredServiceLevelPublisher as the singleton bound
to IServiceLevelPublisher; OtOpcUaServerHostedService gets it injected and
fills it once IServerInternal is available.

Tests boot a real StandardServer on a free port (cross-platform), call
Publish, then verify ServerObject.ServiceLevel.Value reflects the write.
5 new tests; OpcUaServer suite now 45/45 green (was 40, +5).

Closes #81 residual. Unblocks Task 60 (OPC UA dual-endpoint + ServiceLevel
tests).
2026-05-26 10:37:42 -04:00
Joseph Doherty 52997ee164 feat(observability): F13d Prometheus + OpenTelemetry instrumentation
v2-ci / build (push) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
OtOpcUaTelemetry (Commons/Observability) centralizes the project's Meter
+ ActivitySource so all instrumentation points emit through a single
named surface. Counters cover the hot paths:

  otopcua.deploy.applied               (outcome=ack|reject)
  otopcua.deploy.apply.duration        (s, histogram)
  otopcua.driver.lifecycle             (event=spawn|spawn_stub|stop|fault)
  otopcua.virtualtag.eval              (outcome=ok|fail|skip)
  otopcua.scriptedalarm.transition     (state=activated|acknowledged|cleared)
  otopcua.opcua.sink.write             (kind=value|alarm|rebuild)
  otopcua.redundancy.service_level_change (level=byte)

Plus two ActivitySource spans:

  otopcua.deploy.apply                 wraps DriverHostActor.ApplyAndAck
  otopcua.opcua.address_space_rebuild  wraps OpcUaPublishActor.HandleRebuild

Instruments are no-op until a listener attaches, so tests + dev hosts
pay nothing for unread telemetry.

Host Program.cs gains AddOtOpcUaObservability() (binds the OtOpcUa Meter
+ ActivitySource to OpenTelemetry, attaches a Prometheus exporter) and
MapOtOpcUaMetrics() (mounts /metrics scrape endpoint). Driver-side
internals + ASP.NET request metrics deliberately stay off — the scrape
payload is scoped to OtOpcUa signals only.

Tests use MeterListener + ActivityListener to verify
VirtualTagActor.eval, OpcUaPublishActor.AttributeValueUpdate, and
RebuildAddressSpace actually emit on the central instruments. Runtime
suite is 72 / 72 green (+3).

Closes #105. Path A (F13b/c/d) complete; next batch options: #85 UNS
folder hierarchy in SDK, or F8b/F9b production engine bindings.
2026-05-26 10:29:40 -04:00
Joseph Doherty 21eac21409 feat(opcua,host): F13c LDAP-bound UserName validator
Adds IOpcUaUserAuthenticator seam in OpcUaServer.Security with a deny-all
NullOpcUaUserAuthenticator default. OpcUaApplicationHost subscribes to
SessionManager.ImpersonateUser after _application.Start so UserName tokens
flow through the authenticator and either attach a UserIdentity to the
session (Allow) or set IdentityValidationError = BadIdentityTokenRejected
(Deny / authenticator exception). Anonymous + X509 tokens fall through to
SDK defaults.

LdapOpcUaUserAuthenticator (Host project) bridges to the same
ILdapAuthService that AddOtOpcUaAuth uses for Admin cookies / JWT, so a
single LDAP source-of-truth governs both Admin control plane and OPC UA
data plane. Program.cs registers LdapOptions + LdapAuthService +
IOpcUaUserAuthenticator on driver-role hosts; admin-only nodes are
unchanged.

OtOpcUaServerHostedService threads the resolved authenticator into
OpcUaApplicationHost so the seam respects Host DI.

10 new tests: 6 in OpcUaServer.Tests cover the pure HandleImpersonation
static method (success / denial / anonymous fallthrough / authenticator-
throw / null-username / Null authenticator); 4 in Host.IntegrationTests
cover the LdapOpcUaUserAuthenticator adapter (LDAP allow → Allow with
roles, LDAP deny → Deny, exception → backend-error denial, display-name
fallback). OpcUaServer suite is 40 / 40 green.

Closes #104. Unblocks Task 60 (dual-endpoint + ServiceLevel tests) once
#81 residual lands.
2026-05-26 10:21:37 -04:00
Joseph Doherty 8b08566f41 feat(opcua): F13b endpoint security profiles — Sign + SignAndEncrypt
OpcUaApplicationHost.BuildConfigurationAsync now populates
ServerConfiguration.SecurityPolicies + UserTokenPolicies from the new
OpcUaSecurityProfile enum on OpcUaApplicationHostOptions. Defaults expose
all three baseline profiles (None + Basic256Sha256-Sign +
Basic256Sha256-SignAndEncrypt) matching docs/security.md. UserName tokens
are SDK-encrypted with the server cert so they work on None endpoints too;
F13c will plug the LDAP validator into SessionManager.

AutoAcceptUntrustedClientCertificates surfaces as an option for dev flows;
production keeps the default (false) and operators promote rejected certs
through the Admin UI.

InternalsVisibleTo added so BuildSecurityPolicies / BuildUserTokenPolicies
stay encapsulated but unit-testable. 6 new tests cover the pure builders +
two boot-verify cases (3-profile default + hardened single-profile),
bringing the suite to 34 / 34 passing.

Closes #103. Unblocks #104 (F13c LDAP user-token validator).
2026-05-26 10:15:04 -04:00
39 changed files with 2191 additions and 39 deletions
@@ -0,0 +1,81 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace ZB.MOM.WW.OtOpcUa.Commons.Observability;
/// <summary>
/// Central <see cref="Meter"/> + <see cref="ActivitySource"/> definitions for OtOpcUa.
/// All Akka actors, the OPC UA publish path, and the deploy coordinator emit through these
/// pre-created instruments so a single OpenTelemetry / Prometheus binding in <c>Host</c>
/// catches everything. No exporter is required — instruments are no-op until a listener
/// attaches, so tests and dev hosts pay nothing for instrumentation that nobody scrapes.
///
/// Instrument names follow the OpenTelemetry semantic convention pattern
/// <c>otopcua.&lt;subsystem&gt;.&lt;event&gt;</c>. Subsystem is one of: deploy, driver,
/// virtualtag, scriptedalarm, opcua, redundancy.
/// </summary>
public static class OtOpcUaTelemetry
{
public const string MeterName = "ZB.MOM.WW.OtOpcUa";
public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa";
/// <summary>Singleton <see cref="Meter"/> all counters/histograms hang off.</summary>
public static readonly Meter Meter = new(MeterName);
/// <summary>Singleton <see cref="ActivitySource"/> used to start spans wrapping deploy/apply/rebuild.</summary>
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
// ---------------- Deployment / driver-host coordination ----------------
/// <summary>Incremented every time DriverHostActor finishes applying a deployment (Ack or Reject).</summary>
public static readonly Counter<long> DeploymentApplied =
Meter.CreateCounter<long>("otopcua.deploy.applied", unit: "{deployment}",
description: "Deployments applied by a driver-role node (outcome=ack|reject).");
/// <summary>Time from DriverHostActor receiving DispatchDeployment to emitting the ack/reject.</summary>
public static readonly Histogram<double> DeploymentApplyDurationSec =
Meter.CreateHistogram<double>("otopcua.deploy.apply.duration", unit: "s",
description: "Driver-role apply latency from DispatchDeployment → Ack/Reject.");
/// <summary>DriverInstanceActor spawn count (added=new instance; stop=disposed).</summary>
public static readonly Counter<long> DriverInstanceLifecycle =
Meter.CreateCounter<long>("otopcua.driver.lifecycle", unit: "{event}",
description: "DriverInstanceActor lifecycle transitions (event=spawn|stop|fault).");
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
public static readonly Counter<long> VirtualTagEval =
Meter.CreateCounter<long>("otopcua.virtualtag.eval", unit: "{eval}",
description: "Virtual-tag evaluations attempted (outcome=ok|fail|skip).");
public static readonly Counter<long> ScriptedAlarmTransition =
Meter.CreateCounter<long>("otopcua.scriptedalarm.transition", unit: "{transition}",
description: "Scripted-alarm state transitions (state=active|acknowledged|inactive).");
// ---------------- OPC UA address-space + redundancy ----------------
public static readonly Counter<long> OpcUaSinkWrite =
Meter.CreateCounter<long>("otopcua.opcua.sink.write", unit: "{write}",
description: "Writes that landed in IOpcUaAddressSpaceSink (kind=value|alarm|rebuild).");
public static readonly Counter<long> ServiceLevelChange =
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
// ---------------- Convenience helpers ----------------
/// <summary>
/// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns
/// null when no listener is attached so the call site stays cheap on undecorated builds.
/// </summary>
public static Activity? StartDeployApplySpan(string deploymentId)
{
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
activity?.SetTag("otopcua.deployment_id", deploymentId);
return activity;
}
/// <summary>Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply).</summary>
public static Activity? StartAddressSpaceRebuildSpan()
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
}
@@ -27,5 +27,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); => _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
} }
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
/// </summary>
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
{
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
public void SetInner(IServiceLevelPublisher? inner) =>
_inner = inner ?? NullServiceLevelPublisher.Instance;
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
}
@@ -14,6 +14,14 @@ public interface IOpcUaAddressSpaceSink
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary> /// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc); void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
/// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
/// root. Idempotent: calling twice with the same id is safe.
/// </summary>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
/// <summary> /// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a /// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
/// successful deployment apply so the node manager reflects the new config. Idempotent. /// successful deployment apply so the node manager reflects the new config. Idempotent.
@@ -33,5 +41,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
private NullOpcUaAddressSpaceSink() { } private NullOpcUaAddressSpaceSink() { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
} }
@@ -0,0 +1,107 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using SerilogLogger = Serilog.ILogger;
using SerilogLog = Serilog.Log;
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
/// <summary>
/// F9b — production <see cref="IScriptedAlarmEvaluator"/> binding. Compiles each unique
/// predicate once via <see cref="ScriptEvaluator{TContext, TResult}"/> against
/// <see cref="AlarmPredicateContext"/> and caches the resulting evaluator. Predicates are
/// pure functions returning <c>bool</c>: <see cref="AlarmPredicateContext.SetVirtualTag"/>
/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation.
///
/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface
/// as <see cref="ScriptedAlarmEvalResult.Failure"/>; <see cref="ScriptedAlarmActor"/>
/// preserves the prior state on failure (does not flip Active/Inactive).
/// </summary>
public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable
{
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynScriptedAlarmEvaluator>();
private readonly ConcurrentDictionary<string, ScriptEvaluator<AlarmPredicateContext, bool>> _cache
= new(StringComparer.Ordinal);
private readonly ILogger<RoslynScriptedAlarmEvaluator> _logger;
private readonly TimeSpan _runTimeout;
private bool _disposed;
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
{
_logger = logger;
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
}
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
{
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate");
ScriptEvaluator<AlarmPredicateContext, bool> evaluator;
try
{
evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator<AlarmPredicateContext, bool>.Compile);
}
catch (CompilationErrorException ex)
{
_logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId);
return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}");
}
catch (ScriptSandboxViolationException ex)
{
_logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId);
return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId);
return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}");
}
var readCache = BuildReadCache(dependencies);
var context = new AlarmPredicateContext(readCache, ScriptLogger);
try
{
using var cts = new CancellationTokenSource(_runTimeout);
var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
return ScriptedAlarmEvalResult.Ok(active);
}
catch (OperationCanceledException)
{
return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId);
return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}");
}
}
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
var nowUtc = DateTime.UtcNow;
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var kv in deps)
{
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
}
return cache;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var ev in _cache.Values)
{
try { ev.Dispose(); } catch { /* best-effort */ }
}
_cache.Clear();
}
}
@@ -0,0 +1,116 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using SerilogLogger = Serilog.ILogger;
using SerilogLog = Serilog.Log;
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
/// <summary>
/// F8b — production <see cref="IVirtualTagEvaluator"/> binding. Compiles each unique
/// expression once via <see cref="ScriptEvaluator{TContext, TResult}"/> (Roslyn-backed
/// sandbox) and caches the resulting evaluator keyed by source. Subsequent evaluations are
/// in-process method invocations on the dependency dictionary — fast enough to run inline
/// inside the actor's message handler.
///
/// Single-tag mode: cross-tag <c>ctx.SetVirtualTag</c> writes are dropped (logged) because
/// fan-out between actors is owned by <c>DependencyMuxActor</c>, not by the eval engine.
/// Cycle detection + cascade ordering live in <see cref="VirtualTagEngine"/>; this adapter
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
/// </summary>
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
{
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynVirtualTagEvaluator>();
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
= new(StringComparer.Ordinal);
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
private readonly TimeSpan _runTimeout;
private bool _disposed;
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
{
_logger = logger;
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
}
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
ScriptEvaluator<VirtualTagContext, object?> evaluator;
try
{
evaluator = _cache.GetOrAdd(expression, ScriptEvaluator<VirtualTagContext, object?>.Compile);
}
catch (CompilationErrorException ex)
{
_logger.LogWarning(ex, "VirtualTag {Id}: Roslyn compile failed", virtualTagId);
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
}
catch (ScriptSandboxViolationException ex)
{
_logger.LogWarning(ex, "VirtualTag {Id}: sandbox violation", virtualTagId);
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "VirtualTag {Id}: compile threw", virtualTagId);
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
}
var readCache = BuildReadCache(dependencies);
var context = new VirtualTagContext(
readCache,
setVirtualTag: (path, _) =>
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
virtualTagId, path),
logger: ScriptLogger);
try
{
using var cts = new CancellationTokenSource(_runTimeout);
var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
return VirtualTagEvalResult.Ok(raw);
}
catch (OperationCanceledException)
{
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "VirtualTag {Id}: script execution threw", virtualTagId);
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
}
}
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
// values stay null; the script can null-check via GetTag(path).Value.
var nowUtc = DateTime.UtcNow;
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var kv in deps)
{
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
}
return cache;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var ev in _cache.Values)
{
try { ev.Dispose(); } catch { /* best-effort */ }
}
_cache.Clear();
}
}
@@ -0,0 +1,38 @@
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
namespace ZB.MOM.WW.OtOpcUa.Host.Observability;
/// <summary>
/// Wires the OtOpcUa Meter + ActivitySource into OpenTelemetry and exposes a Prometheus
/// scrape endpoint at <c>/metrics</c> on the host pipeline. F13d slice — only the meter +
/// activity source declared in <see cref="OtOpcUaTelemetry"/> are surfaced; per-Akka
/// internals + ASP.NET request metrics stay off by default to keep the scrape payload
/// scoped to OtOpcUa-owned signals.
/// </summary>
public static class ObservabilityExtensions
{
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services)
{
services.AddOpenTelemetry()
.WithMetrics(b => b
.AddMeter(OtOpcUaTelemetry.MeterName)
.AddPrometheusExporter())
.WithTracing(b => b
.AddSource(OtOpcUaTelemetry.ActivitySourceName));
return services;
}
/// <summary>
/// Mounts the Prometheus scrape endpoint on the existing ASP.NET pipeline. Call after
/// <c>app.UseAuthentication/UseAuthorization</c> if metrics access should require auth;
/// the default leaves it unauthenticated for local Prometheus scrapes.
/// </summary>
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
{
app.MapPrometheusScrapingEndpoint("/metrics");
return app;
}
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
/// them off <c>OperationContext.UserIdentity</c> downstream.
/// </summary>
public sealed class LdapOpcUaUserAuthenticator(
ILdapAuthService ldap,
ILogger<LdapOpcUaUserAuthenticator> logger)
: IOpcUaUserAuthenticator
{
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
try
{
var result = await ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
if (!result.Success)
{
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
}
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username);
return OpcUaUserAuthResult.Deny("Authentication backend error");
}
}
}
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
@@ -21,6 +22,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly DeferredAddressSpaceSink _deferredSink; private readonly DeferredAddressSpaceSink _deferredSink;
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OtOpcUaServerHostedService> _logger; private readonly ILogger<OtOpcUaServerHostedService> _logger;
@@ -30,10 +33,14 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
public OtOpcUaServerHostedService( public OtOpcUaServerHostedService(
IConfiguration configuration, IConfiguration configuration,
DeferredAddressSpaceSink deferredSink, DeferredAddressSpaceSink deferredSink,
DeferredServiceLevelPublisher deferredServiceLevel,
IOpcUaUserAuthenticator userAuthenticator,
ILoggerFactory loggerFactory) ILoggerFactory loggerFactory)
{ {
_configuration = configuration; _configuration = configuration;
_deferredSink = deferredSink; _deferredSink = deferredSink;
_deferredServiceLevel = deferredServiceLevel;
_userAuthenticator = userAuthenticator;
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>(); _logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
} }
@@ -44,7 +51,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_configuration.GetSection("OpcUa").Bind(options); _configuration.GetSection("OpcUa").Bind(options);
_server = new OtOpcUaSdkServer(); _server = new OtOpcUaSdkServer();
_appHost = new OpcUaApplicationHost(options, _loggerFactory.CreateLogger<OpcUaApplicationHost>()); _appHost = new OpcUaApplicationHost(
options,
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
_userAuthenticator);
try try
{ {
@@ -68,14 +78,24 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
} }
_deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager)); _deferredSink.SetSink(new SdkAddressSpaceSink(_server.NodeManager));
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space sink bound");
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
{
_deferredServiceLevel.SetInner(new SdkServiceLevelPublisher(
serverInternal,
_loggerFactory.CreateLogger<SdkServiceLevelPublisher>()));
}
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
} }
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
// Revert to Null sink so any in-flight writes from a poison-pilled actor don't hit a // Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
// half-disposed NodeManager. // half-disposed NodeManager.
_deferredSink.SetSink(null); _deferredSink.SetSink(null);
_deferredServiceLevel.SetInner(null);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -8,13 +8,18 @@ using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Host; using ZB.MOM.WW.OtOpcUa.Host;
using ZB.MOM.WW.OtOpcUa.Host.Drivers; using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
using ZB.MOM.WW.OtOpcUa.Host.Health; using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.Observability;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa; using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security; using ZB.MOM.WW.OtOpcUa.Security;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints; using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser. // Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")); var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
@@ -56,6 +61,34 @@ if (hasDriver)
builder.Services.AddSingleton<DeferredAddressSpaceSink>(); builder.Services.AddSingleton<DeferredAddressSpaceSink>();
builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp => builder.Services.AddSingleton<IOpcUaAddressSpaceSink>(sp =>
sp.GetRequiredService<DeferredAddressSpaceSink>()); sp.GetRequiredService<DeferredAddressSpaceSink>());
// Same late-binding pattern for the ServiceLevel publisher — actor wants it at ctor time,
// production SdkServiceLevelPublisher needs IServerInternal which only exists after Start.
builder.Services.AddSingleton<DeferredServiceLevelPublisher>();
builder.Services.AddSingleton<IServiceLevelPublisher>(sp =>
sp.GetRequiredService<DeferredServiceLevelPublisher>());
// F13c — bind UserName tokens to the same LDAP backend the Admin cookie/JWT flows use.
// ILdapAuthService is registered by AddOtOpcUaAuth on admin nodes; on driver-only nodes
// it isn't, so we register the LDAP options + service unconditionally for driver hosts
// to keep parity. The LdapAdapter falls back to Deny on any backend error.
// F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression).
// Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user
// scripts at runtime.
builder.Services.AddSingleton<RoslynVirtualTagEvaluator>(sp =>
new RoslynVirtualTagEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynVirtualTagEvaluator>()));
builder.Services.AddSingleton<IVirtualTagEvaluator>(sp => sp.GetRequiredService<RoslynVirtualTagEvaluator>());
// F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on
// any Failure result, so a misbehaving script can't flip Active/Inactive spuriously.
builder.Services.AddSingleton<RoslynScriptedAlarmEvaluator>(sp =>
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
builder.Services.AddHostedService<OtOpcUaServerHostedService>(); builder.Services.AddHostedService<OtOpcUaServerHostedService>();
} }
@@ -83,6 +116,7 @@ if (hasAdmin)
} }
builder.Services.AddOtOpcUaHealth(); builder.Services.AddOtOpcUaHealth();
builder.Services.AddOtOpcUaObservability();
var app = builder.Build(); var app = builder.Build();
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
@@ -98,6 +132,7 @@ if (hasAdmin)
} }
app.MapOtOpcUaHealth(); app.MapOtOpcUaHealth();
app.MapOtOpcUaMetrics();
Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})", Log.Information("OtOpcUa.Host starting with roles=[{Roles}] (admin={HasAdmin}, driver={HasDriver})",
string.Join(",", roles), hasAdmin, hasDriver); string.Join(",", roles), hasAdmin, hasDriver);
@@ -5,6 +5,12 @@
<AssemblyName>OtOpcUa.Host</AssemblyName> <AssemblyName>OtOpcUa.Host</AssemblyName>
<UserSecretsId>zb-mom-ww-otopcua-host</UserSecretsId> <UserSecretsId>zb-mom-ww-otopcua-host</UserSecretsId>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Microsoft.CodeAnalysis.CSharp.Scripting (4.12.0, pulled in via Core.Scripting for F8b
user-script compilation) requires CodeAnalysis.Common 4.12.0 exactly, but ASP.NET
Core's transitive Microsoft.CodeAnalysis.CSharp 5.0.0 wins resolution. Suppress
NU1608 — the surface we use from Scripting (ScriptEvaluator + RoslynScriptHost) is
stable across the version drift; verified by Core.Scripting.Tests. -->
<NoWarn>$(NoWarn);NU1608</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -15,6 +21,8 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="OpenTelemetry.Extensions.Hosting"/>
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -22,6 +30,9 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.ControlPlane\ZB.MOM.WW.OtOpcUa.ControlPlane.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
@@ -2,9 +2,26 @@ using Microsoft.Extensions.Logging;
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Configuration; using Opc.Ua.Configuration;
using Opc.Ua.Server; using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
/// </summary>
public enum OpcUaSecurityProfile
{
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
None,
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
Basic256Sha256Sign,
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
Basic256Sha256SignAndEncrypt,
}
public sealed class OpcUaApplicationHostOptions public sealed class OpcUaApplicationHostOptions
{ {
public string ApplicationName { get; set; } = "OtOpcUa"; public string ApplicationName { get; set; } = "OtOpcUa";
@@ -26,6 +43,26 @@ public sealed class OpcUaApplicationHostOptions
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1. /// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
/// </summary> /// </summary>
public string PkiStoreRoot { get; set; } = "pki"; public string PkiStoreRoot { get; set; } = "pki";
/// <summary>
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
/// descriptor per profile and clients choose at session open. Default = all three
/// baseline profiles (None + Basic256Sha256 in both modes); production deployments
/// typically drop None.
/// </summary>
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
};
/// <summary>
/// When true, unknown client certificates are auto-added to the trusted store on first
/// connection. Convenient for dev; should be false in production (operators promote via
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; set; }
} }
/// <summary> /// <summary>
@@ -41,16 +78,20 @@ public sealed class OpcUaApplicationHostOptions
public sealed class OpcUaApplicationHost : IAsyncDisposable public sealed class OpcUaApplicationHost : IAsyncDisposable
{ {
private readonly OpcUaApplicationHostOptions _options; private readonly OpcUaApplicationHostOptions _options;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILogger<OpcUaApplicationHost> _logger; private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application; private ApplicationInstance? _application;
private StandardServer? _server; private StandardServer? _server;
private ImpersonateEventHandler? _impersonateHandler;
public OpcUaApplicationHost( public OpcUaApplicationHost(
OpcUaApplicationHostOptions options, OpcUaApplicationHostOptions options,
ILogger<OpcUaApplicationHost> logger) ILogger<OpcUaApplicationHost> logger,
IOpcUaUserAuthenticator? userAuthenticator = null)
{ {
_options = options; _options = options;
_logger = logger; _logger = logger;
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
} }
public ApplicationInstance? ApplicationInstance => _application; public ApplicationInstance? ApplicationInstance => _application;
@@ -70,10 +111,99 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false); await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
await _application.Start(server).ConfigureAwait(false); await _application.Start(server).ConfigureAwait(false);
AttachUserAuthenticator();
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}", _logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
_options.PublicHostname, _options.OpcUaPort); _options.PublicHostname, _options.OpcUaPort);
} }
/// <summary>
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
///
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
/// LDAP binds typically complete in &lt;100 ms; if a backing store ever gets that slow
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
/// </summary>
private void AttachUserAuthenticator()
{
var sessionManager = _server?.CurrentInstance?.SessionManager;
if (sessionManager is null)
{
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
return;
}
_impersonateHandler = OnImpersonateUser;
sessionManager.ImpersonateUser += _impersonateHandler;
}
private void OnImpersonateUser(Session session, ImpersonateEventArgs args) =>
HandleImpersonation(_userAuthenticator, args, _logger);
/// <summary>
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
/// and logging.
/// </summary>
internal static void HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger)
{
if (args.NewIdentity is not UserNameIdentityToken token)
{
// Anonymous + X509 tokens — let the SDK's default validation stand.
return;
}
string password;
try
{
password = token.DecryptedPassword ?? string.Empty;
}
catch (Exception ex)
{
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"UserName token decryption failed");
return;
}
OpcUaUserAuthResult result;
try
{
result = authenticator
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Authentication failed");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
token.UserName, result.Error);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Invalid credentials");
return;
}
args.Identity = new UserIdentity(token);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} ({Roles})",
token.UserName, string.Join(",", result.Roles));
}
/// <summary> /// <summary>
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>. /// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh /// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
@@ -103,21 +233,30 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true); return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true);
} }
// Minimal defaults — security and certificate stores hardcoded to local files in var serverConfig = new ServerConfiguration
// the app's working directory. Full security wiring stays in legacy Server until F13. {
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
};
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
{
serverConfig.SecurityPolicies.Add(policy);
}
foreach (var token in BuildUserTokenPolicies())
{
serverConfig.UserTokenPolicies.Add(token);
}
var config = new ApplicationConfiguration var config = new ApplicationConfiguration
{ {
ApplicationName = _options.ApplicationName, ApplicationName = _options.ApplicationName,
ApplicationUri = _options.ApplicationUri, ApplicationUri = _options.ApplicationUri,
ProductUri = _options.ProductUri, ProductUri = _options.ProductUri,
ApplicationType = ApplicationType.Server, ApplicationType = ApplicationType.Server,
ServerConfiguration = new ServerConfiguration ServerConfiguration = serverConfig,
{
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
},
SecurityConfiguration = new SecurityConfiguration SecurityConfiguration = new SecurityConfiguration
{ {
ApplicationCertificate = new CertificateIdentifier ApplicationCertificate = new CertificateIdentifier
@@ -129,7 +268,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") }, TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") }, RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
AutoAcceptUntrustedCertificates = false, AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
}, },
TransportQuotas = new TransportQuotas(), TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(), ClientConfiguration = new ClientConfiguration(),
@@ -141,8 +280,80 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
return config; return config;
} }
/// <summary>
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
/// falls back to a single None entry so the server doesn't refuse to start with no
/// listening endpoints — the misconfiguration is logged and very visible.
/// </summary>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
var any = false;
foreach (var profile in profiles)
{
if (!seen.Add(profile)) continue;
any = true;
yield return profile switch
{
OpcUaSecurityProfile.None => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
};
}
if (!any)
{
yield return new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
};
}
}
/// <summary>
/// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
/// the server certificate (see docs/security.md "UserName token encryption") so the
/// policy works on None endpoints too. F13c will plug a real LDAP-bound validator into
/// <c>StandardServer.SessionManager.ImpersonateUser</c>.
/// </summary>
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
{
yield return new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "anonymous",
SecurityPolicyUri = SecurityPolicies.None,
};
yield return new UserTokenPolicy(UserTokenType.UserName)
{
PolicyId = "username_basic256sha256",
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
};
}
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
{
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
}
_impersonateHandler = null;
try { _application?.Stop(); } try { _application?.Stop(); }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
@@ -27,6 +27,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns"; public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
private FolderState? _root; private FolderState? _root;
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration) public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
@@ -36,6 +37,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
} }
public int VariableCount => _variables.Count; public int VariableCount => _variables.Count;
public int FolderCount => _folders.Count;
/// <summary> /// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the /// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
@@ -73,9 +75,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
} }
} }
/// <summary>Clear every registered variable from the address space. Phase7Applier calls this /// <summary>
/// when Equipment/Alarm topology changes; the populator then re-adds via WriteValue on the /// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
/// next pass.</summary> /// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
/// #85 — used by <see cref="Phase7Applier"/> to materialise the UNS Area/Line/Equipment
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
/// folder so adding child variables under it still works.
/// </summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
if (_folders.ContainsKey(folderNodeId)) return;
lock (Lock)
{
if (_folders.ContainsKey(folderNodeId)) return;
var parent = ResolveParentFolder(parentNodeId);
var folder = new FolderState(parent)
{
NodeId = new NodeId(folderNodeId, NamespaceIndex),
BrowseName = new QualifiedName(folderNodeId, NamespaceIndex),
DisplayName = displayName,
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
};
parent.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
_folders[folderNodeId] = folder;
}
}
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
/// EnsureFolder + WriteValue on the next pass.</summary>
public void RebuildAddressSpace() public void RebuildAddressSpace()
{ {
lock (Lock) lock (Lock)
@@ -86,9 +122,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PredefinedNodes?.Remove(v.NodeId); PredefinedNodes?.Remove(v.NodeId);
} }
_variables.Clear(); _variables.Clear();
foreach (var f in _folders.Values)
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
_folders.Clear();
} }
} }
private FolderState ResolveParentFolder(string? parentNodeId)
{
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
}
/// <inheritdoc /> /// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences) public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{ {
@@ -94,6 +94,43 @@ public sealed class Phase7Applier
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild); return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
} }
/// <summary>
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
/// present, so re-applies are cheap.
/// </summary>
public void MaterialiseHierarchy(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
foreach (var area in composition.UnsAreas)
{
SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName);
}
foreach (var line in composition.UnsLines)
{
SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName);
}
foreach (var equipment in composition.EquipmentNodes)
{
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName);
}
_logger.LogInformation(
"Phase7Applier: hierarchy materialised (areas={Areas}, lines={Lines}, equipment={Equipment})",
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
}
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
}
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts) private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
{ {
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); } try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
@@ -2,12 +2,30 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.</summary> /// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
public sealed record Phase7CompositionResult( public sealed record Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> UnsAreas,
IReadOnlyList<UnsLineProjection> UnsLines,
IReadOnlyList<EquipmentNode> EquipmentNodes, IReadOnlyList<EquipmentNode> EquipmentNodes,
IReadOnlyList<DriverInstancePlan> DriverInstancePlans, IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans); IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
{
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
public Phase7CompositionResult(
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
{
}
}
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId); public sealed record EquipmentNode(string EquipmentId, string DisplayName, string UnsLineId);
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson); public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate); public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
@@ -17,18 +35,38 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role /// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
/// startup (Task 53) consumes the result and hands it to the node-manager factory. /// startup (Task 53) consumes the result and hands it to the node-manager factory.
/// ///
/// Full migration of the legacy <c>Server.Phase7.Phase7Composer</c> (which mutates a server-side /// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
/// node cache, emits trace logs, and calls into <c>EquipmentNodeWalker</c>) is tracked as /// <see cref="UnsLineProjection"/>) so <c>Phase7Applier</c> can build the
/// follow-up F14. This pure version handles the projection step; the side-effecting wiring /// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
/// stays in the legacy code until F14 lands. /// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
/// (composer → applier → sink → node manager) chain.
/// </summary> /// </summary>
public static class Phase7Composer public static class Phase7Composer
{ {
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
public static Phase7CompositionResult Compose( public static Phase7CompositionResult Compose(
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment, IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances, IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms) IReadOnlyList<ScriptedAlarm> scriptedAlarms)
{ {
var areas = unsAreas
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
.Select(a => new UnsAreaProjection(a.UnsAreaId, a.Name))
.ToList();
var lines = unsLines
.OrderBy(l => l.UnsLineId, StringComparer.Ordinal)
.Select(l => new UnsLineProjection(l.UnsLineId, l.UnsAreaId, l.Name))
.ToList();
var nodes = equipment var nodes = equipment
.OrderBy(e => e.EquipmentId, StringComparer.Ordinal) .OrderBy(e => e.EquipmentId, StringComparer.Ordinal)
.Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId)) .Select(e => new EquipmentNode(e.EquipmentId, e.MachineCode, e.UnsLineId))
@@ -44,6 +82,6 @@ public static class Phase7Composer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate)) .Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList(); .ToList();
return new Phase7CompositionResult(nodes, plans, alarms); return new Phase7CompositionResult(areas, lines, nodes, plans, alarms);
} }
} }
@@ -24,5 +24,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); => _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace(); public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
} }
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Production <see cref="IServiceLevelPublisher"/> that writes the OPC UA Server object's
/// <c>ServiceLevel</c> Variable through the SDK. Clients reading
/// <c>VariableIds.Server_ServiceLevel</c> see the live value updated whenever the redundancy
/// state changes — that's the standard OPC UA non-transparent-redundancy signal callers use
/// to pick a primary.
///
/// Uses <see cref="IServerInternal.ServerObject"/> (a <see cref="ServerObjectState"/>) and
/// its <see cref="ServerObjectState.ServiceLevel"/> child variable, which the SDK populates
/// automatically during <see cref="DiagnosticsNodeManager"/> initialization. Writes are
/// guarded by <see cref="IServerInternal.DiagnosticsLock"/> so concurrent diagnostics scans
/// from the SDK don't fight with our update.
/// </summary>
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
{
private readonly IServerInternal _serverInternal;
private readonly ILogger<SdkServiceLevelPublisher> _logger;
public SdkServiceLevelPublisher(IServerInternal serverInternal, ILogger<SdkServiceLevelPublisher> logger)
{
_serverInternal = serverInternal;
_logger = logger;
}
public void Publish(byte serviceLevel)
{
var node = _serverInternal.ServerObject?.ServiceLevel;
if (node is null)
{
_logger.LogWarning("SdkServiceLevelPublisher: ServerObject.ServiceLevel unavailable; skipping write");
return;
}
try
{
lock (_serverInternal.DiagnosticsLock)
{
node.Value = serviceLevel;
node.Timestamp = DateTime.UtcNow;
node.StatusCode = StatusCodes.Good;
node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
}
}
}
@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
/// <summary>
/// Validates OPC UA UserName tokens. The SDK already decrypts the token (using the server
/// application cert) and hands the cleartext username + password to this seam. Implementations
/// decide whether the credentials are valid and what roles to attach for downstream ACL checks.
///
/// Production implementation lives in the Host project (wraps <c>ILdapAuthService</c>); the
/// <see cref="NullOpcUaUserAuthenticator"/> default rejects every attempt so misconfigured
/// dev nodes don't silently accept credentials.
/// </summary>
public interface IOpcUaUserAuthenticator
{
/// <summary>
/// Resolves cleartext UserName credentials against the configured backing store. Must not
/// throw — callers turn results into <c>ImpersonateEventArgs.IdentityValidationError</c>
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
/// path where it surfaces as a generic <c>BadInternalError</c>.
/// </summary>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
}
/// <summary>Outcome of a UserName authentication attempt. <see cref="Roles"/> populates the session identity's role set.</summary>
public sealed record OpcUaUserAuthResult(
bool Success,
string? DisplayName,
IReadOnlyList<string> Roles,
string? Error)
{
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
new(true, displayName, roles, null);
public static OpcUaUserAuthResult Deny(string error) =>
new(false, null, Array.Empty<string>(), error);
}
/// <summary>
/// Default deny-all authenticator. Wired by <c>OpcUaApplicationHost</c> when no production
/// authenticator is registered in DI — keeps the server safe-by-default rather than accepting
/// arbitrary UserName credentials. Production Host DI overrides this with the LDAP adapter.
/// </summary>
public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
{
public static readonly NullOpcUaUserAuthenticator Instance = new();
private NullOpcUaUserAuthenticator() { }
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
}
@@ -19,6 +19,10 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/> <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/> <NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
@@ -89,34 +89,34 @@ public static class DeploymentArtifact
/// </summary> /// </summary>
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob) public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
{ {
if (blob.IsEmpty) if (blob.IsEmpty) return Empty();
{
return new Phase7CompositionResult(
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
}
try try
{ {
using var doc = JsonDocument.Parse(blob.ToArray()); using var doc = JsonDocument.Parse(blob.ToArray());
var root = doc.RootElement; var root = doc.RootElement;
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode); var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
return new Phase7CompositionResult(equipment, drivers, alarms); return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
} }
catch (JsonException) catch (JsonException)
{ {
return new Phase7CompositionResult( return Empty();
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
} }
} }
private static Phase7CompositionResult Empty() => new(
Array.Empty<UnsAreaProjection>(),
Array.Empty<UnsLineProjection>(),
Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>());
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader) private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
where T : class where T : class
{ {
@@ -137,12 +137,31 @@ public static class DeploymentArtifact
private static string IdentityOf<T>(T item) where T : class => item switch private static string IdentityOf<T>(T item) where T : class => item switch
{ {
UnsAreaProjection a => a.UnsAreaId,
UnsLineProjection l => l.UnsLineId,
EquipmentNode e => e.EquipmentId, EquipmentNode e => e.EquipmentId,
DriverInstancePlan d => d.DriverInstanceId, DriverInstancePlan d => d.DriverInstanceId,
ScriptedAlarmPlan a => a.ScriptedAlarmId, ScriptedAlarmPlan a => a.ScriptedAlarmId,
_ => string.Empty, _ => string.Empty,
}; };
private static UnsAreaProjection? ReadAreaProjection(JsonElement el)
{
var id = el.TryGetProperty("UnsAreaId", out var idEl) ? idEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) return null;
return new UnsAreaProjection(id!, name ?? id!);
}
private static UnsLineProjection? ReadLineProjection(JsonElement el)
{
var id = el.TryGetProperty("UnsLineId", out var idEl) ? idEl.GetString() : null;
var areaId = el.TryGetProperty("UnsAreaId", out var areaEl) ? areaEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(areaId)) return null;
return new UnsLineProjection(id!, areaId!, name ?? id!);
}
private static EquipmentNode? ReadEquipmentNode(JsonElement el) private static EquipmentNode? ReadEquipmentNode(JsonElement el)
{ {
var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null; var id = el.TryGetProperty("EquipmentId", out var idEl) ? idEl.GetString() : null;
@@ -1,3 +1,4 @@
using System.Diagnostics;
using Akka.Actor; using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe; using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
@@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -239,6 +241,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_applyingDeploymentId = deploymentId; _applyingDeploymentId = deploymentId;
Become(Applying); Become(Applying);
using var span = OtOpcUaTelemetry.StartDeployApplySpan(deploymentId.ToString());
span?.SetTag("otopcua.node_id", _localNode.ToString());
span?.SetTag("otopcua.revision", revision.ToString());
span?.SetTag("otopcua.correlation_id", correlation.ToString());
var sw = Stopwatch.StartNew();
// Persist Applying row (idempotent on PK). // Persist Applying row (idempotent on PK).
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applying, failureReason: null); UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Applying, failureReason: null);
@@ -252,6 +260,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// composition. The publish actor handles the load-compose-diff-apply pipeline; we // composition. The publish actor handles the load-compose-diff-apply pipeline; we
// just forward the same correlation id so the audit trail joins up. // just forward the same correlation id so the audit trail joins up.
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation)); _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation));
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "ack"));
_log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})", _log.Info("DriverHost {Node}: applied deployment {Id} (rev {Rev}, children={Count})",
_localNode, deploymentId, revision, _children.Count); _localNode, deploymentId, revision, _children.Count);
} }
@@ -259,10 +268,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{ {
UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Failed, ex.Message); UpsertNodeDeploymentState(deploymentId, NodeDeploymentStatus.Failed, ex.Message);
SendAck(deploymentId, ApplyAckOutcome.Failed, ex.Message, correlation); SendAck(deploymentId, ApplyAckOutcome.Failed, ex.Message, correlation);
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "reject"));
span?.SetStatus(ActivityStatusCode.Error, ex.Message);
_log.Error(ex, "DriverHost {Node}: apply of {Id} failed", _localNode, deploymentId); _log.Error(ex, "DriverHost {Node}: apply of {Id} failed", _localNode, deploymentId);
} }
finally finally
{ {
OtOpcUaTelemetry.DeploymentApplyDurationSec.Record(sw.Elapsed.TotalSeconds);
_applyingDeploymentId = null; _applyingDeploymentId = null;
Become(Steady); Become(Steady);
} }
@@ -1,5 +1,6 @@
using Akka.Actor; using Akka.Actor;
using Akka.Event; using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -82,6 +83,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_driver = driver; _driver = driver;
_driverInstanceId = driver.DriverInstanceId; _driverInstanceId = driver.DriverInstanceId;
_reconnectInterval = reconnectInterval; _reconnectInterval = reconnectInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
new KeyValuePair<string, object?>("driver_type", driver.DriverType));
if (startStubbed) if (startStubbed)
{ {
Context.GetLogger().Info("[DEV-STUB] driver={Name} type={Type}", Context.GetLogger().Info("[DEV-STUB] driver={Name} type={Type}",
@@ -314,5 +318,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
DetachSubscription(); DetachSubscription();
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); } try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); } catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", "stop"),
new KeyValuePair<string, object?>("driver_type", _driver.DriverType));
} }
} }
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
@@ -124,6 +125,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
{ {
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc); _sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
Interlocked.Increment(ref _writes); Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -137,6 +139,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
{ {
_sink.WriteAlarmState(msg.AlarmNodeId, msg.Active, msg.Acknowledged, msg.TimestampUtc); _sink.WriteAlarmState(msg.AlarmNodeId, msg.Active, msg.Acknowledged, msg.TimestampUtc);
Interlocked.Increment(ref _writes); Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -146,12 +149,19 @@ public sealed class OpcUaPublishActor : ReceiveActor
private void HandleRebuild(RebuildAddressSpace msg) private void HandleRebuild(RebuildAddressSpace msg)
{ {
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
// Two modes: when dbFactory + applier are wired, do a real diff-and-apply pass against // Two modes: when dbFactory + applier are wired, do a real diff-and-apply pass against
// the latest deployment artifact. Without them, fall back to a raw sink rebuild — the // the latest deployment artifact. Without them, fall back to a raw sink rebuild — the
// F10b/dev path before the integration completes. // F10b/dev path before the integration completes.
if (_dbFactory is null || _applier is null) if (_dbFactory is null || _applier is null)
{ {
try { _sink.RebuildAddressSpace(); } try
{
_sink.RebuildAddressSpace();
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
}
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", _log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
@@ -175,6 +185,13 @@ public sealed class OpcUaPublishActor : ReceiveActor
var outcome = _applier.Apply(plan); var outcome = _applier.Apply(plan);
_lastApplied = composition; _lastApplied = composition;
// #85 — after the plan diff lands, rebuild the UNS folder hierarchy so OPC UA
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
// skips folders that already exist with the same node id.
_applier.MaterialiseHierarchy(composition);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})", _log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled); msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
} }
@@ -211,6 +228,8 @@ public sealed class OpcUaPublishActor : ReceiveActor
try try
{ {
_serviceLevel.Publish(msg.ServiceLevel); _serviceLevel.Publish(msg.ServiceLevel);
OtOpcUaTelemetry.ServiceLevelChange.Add(1,
new KeyValuePair<string, object?>("level", msg.ServiceLevel));
_log.Debug("OpcUaPublish: ServiceLevel={Level}", msg.ServiceLevel); _log.Debug("OpcUaPublish: ServiceLevel={Level}", msg.ServiceLevel);
} }
catch (Exception ex) catch (Exception ex)
@@ -4,6 +4,7 @@ using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
@@ -173,6 +174,9 @@ public sealed class ScriptedAlarmActor : ReceiveActor
_ => next.ToString(), _ => next.ToString(),
}; };
OtOpcUaTelemetry.ScriptedAlarmTransition.Add(1,
new KeyValuePair<string, object?>("state", kind.ToLowerInvariant()));
var evt = new AlarmTransitionEvent( var evt = new AlarmTransitionEvent(
AlarmId: _config.AlarmId, AlarmId: _config.AlarmId,
EquipmentPath: _config.EquipmentPath, EquipmentPath: _config.EquipmentPath,
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
@@ -95,24 +96,35 @@ public sealed class VirtualTagActor : ReceiveActor
catch (Exception ex) catch (Exception ex)
{ {
_log.Warning(ex, "VirtualTag {Id}: evaluator threw", _virtualTagId); _log.Warning(ex, "VirtualTag {Id}: evaluator threw", _virtualTagId);
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
PublishLog("Error", $"evaluator threw: {ex.Message}"); PublishLog("Error", $"evaluator threw: {ex.Message}");
return; return;
} }
if (!result.Success) if (!result.Success)
{ {
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "fail"));
PublishLog("Warning", result.Reason ?? "evaluator failure"); PublishLog("Warning", result.Reason ?? "evaluator failure");
return; return;
} }
// Skip no-change results. Real evaluator returns Ok(value); Null returns NoChange — both // Skip no-change results. Real evaluator returns Ok(value); Null returns NoChange — both
// safe because Null never produces a fresh value. // safe because Null never produces a fresh value.
if (ReferenceEquals(result, VirtualTagEvalResult.NoChange)) return; if (ReferenceEquals(result, VirtualTagEvalResult.NoChange))
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
return;
}
if (_hasLastValue && Equals(_lastValue, result.Value)) return; if (_hasLastValue && Equals(_lastValue, result.Value))
{
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "skip"));
return;
}
_hasLastValue = true; _hasLastValue = true;
_lastValue = result.Value; _lastValue = result.Value;
OtOpcUaTelemetry.VirtualTagEval.Add(1, new KeyValuePair<string, object?>("outcome", "ok"));
var evalResult = new EvaluationResult(_virtualTagId, result.Value, msg.TimestampUtc, CorrelationId.NewId()); var evalResult = new EvaluationResult(_virtualTagId, result.Value, msg.TimestampUtc, CorrelationId.NewId());
Context.Parent.Tell(evalResult); Context.Parent.Tell(evalResult);
} }
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
/// backend exceptions into a denial rather than letting them escape into the SDK.
/// </summary>
public sealed class LdapOpcUaUserAuthenticatorTests
{
[Fact]
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
{
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("Alice");
result.Roles.ShouldBe(new[] { "ConfigEditor" });
}
[Fact]
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
{
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldBe("Invalid username or password");
}
[Fact]
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
{
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("backend");
}
[Fact]
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
{
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
result.Success.ShouldBeTrue();
result.DisplayName.ShouldBe("alice");
}
private sealed class FakeLdap : ILdapAuthService
{
private readonly Func<string, LdapAuthResult> _handler;
public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_;
public FakeLdap(Func<string, LdapAuthResult> handler) => _handler = handler;
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
=> Task.FromResult(_handler(username));
}
}
@@ -0,0 +1,102 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
/// AlarmPredicateContext throws on writes — predicates must stay pure).
/// </summary>
public sealed class RoslynScriptedAlarmEvaluatorTests
{
[Fact]
public void Evaluate_predicate_returning_true_reports_Active()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
var result = sut.Evaluate(
alarmId: "alarm-hi",
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeTrue();
}
[Fact]
public void Evaluate_predicate_returning_false_reports_Inactive()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
var result = sut.Evaluate(
alarmId: "alarm-hi",
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeFalse();
}
[Fact]
public void Evaluate_caches_compiled_predicate_across_calls()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
first.Active.ShouldBeTrue();
second.Active.ShouldBeFalse();
}
[Fact]
public void Evaluate_compile_error_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("compile");
}
[Fact]
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
var result = sut.Evaluate(
alarmId: "alarm-bad-write",
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
dependencies: new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("threw");
}
[Fact]
public void Evaluate_empty_predicate_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
}
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
sut.Dispose();
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("disposed");
}
}
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// F8b — verifies <see cref="RoslynVirtualTagEvaluator"/> compiles user expressions through
/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the
/// compiled assembly per source, and surfaces failures (compile error, sandbox violation,
/// runtime throw) as <c>VirtualTagEvalResult.Failure</c> instead of propagating exceptions.
/// </summary>
public sealed class RoslynVirtualTagEvaluatorTests
{
[Fact]
public void Evaluate_simple_addition_returns_summed_value()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
var result = sut.Evaluate(
virtualTagId: "vt-sum",
expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;",
dependencies: new Dictionary<string, object?> { ["a"] = 10, ["b"] = 32 });
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
[Fact]
public void Evaluate_caches_compiled_expression_across_calls()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
var second = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });
first.Success.ShouldBeTrue(first.Reason);
first.Value.ShouldBe(10);
second.Success.ShouldBeTrue(second.Reason);
second.Value.ShouldBe(14);
}
[Fact]
public void Evaluate_compile_error_returns_Failure_with_reason()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
result.Reason.ShouldContain("compile");
}
[Fact]
public void Evaluate_runtime_exception_returns_Failure_with_reason()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
var result = sut.Evaluate(
virtualTagId: "vt-div0",
expression: "int a = 0; return 1 / a;",
dependencies: new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
result.Reason.ShouldContain("threw");
}
[Fact]
public void Evaluate_empty_expression_returns_Failure()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
}
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
sut.Dispose();
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("disposed");
}
}
@@ -72,6 +72,8 @@ public sealed class DeferredAddressSpaceSinkTests
=> CallQueue.Enqueue($"WV:{nodeId}"); => CallQueue.Enqueue($"WV:{nodeId}");
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> CallQueue.Enqueue($"WA:{alarmNodeId}"); => CallQueue.Enqueue($"WA:{alarmNodeId}");
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
public void RebuildAddressSpace() => CallQueue.Enqueue("RB"); public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
} }
} }
@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class DeferredServiceLevelPublisherTests
{
[Fact]
public void Publish_before_SetInner_is_a_safe_noop()
{
var deferred = new DeferredServiceLevelPublisher();
Should.NotThrow(() => deferred.Publish(123));
}
[Fact]
public void Publish_after_SetInner_routes_to_the_inner()
{
var recording = new RecordingPublisher();
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(recording);
deferred.Publish(200);
recording.LastValue.ShouldBe((byte)200);
}
[Fact]
public void SetInner_null_reverts_to_Null_publisher()
{
var recording = new RecordingPublisher();
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(recording);
deferred.Publish(50);
deferred.SetInner(null);
deferred.Publish(99);
recording.LastValue.ShouldBe((byte)50, "writes after SetInner(null) must not reach the previous inner");
}
private sealed class RecordingPublisher : IServiceLevelPublisher
{
public byte? LastValue { get; private set; }
public void Publish(byte serviceLevel) => LastValue = serviceLevel;
}
}
@@ -0,0 +1,118 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// F13c — verifies the impersonation handler routes UserName tokens through
/// <see cref="IOpcUaUserAuthenticator"/> and translates its result into the SDK's
/// <see cref="ImpersonateEventArgs"/> shape (granted identity vs. rejection status).
/// </summary>
public sealed class OpcUaApplicationHostImpersonationTests
{
private static readonly UserTokenPolicy UserNamePolicy = new(UserTokenType.UserName) { PolicyId = "username_basic256sha256" };
private static readonly UserTokenPolicy AnonPolicy = new(UserTokenType.Anonymous) { PolicyId = "anonymous" };
[Fact]
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
{
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = "secret" };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(
OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.Identity.ShouldNotBeNull();
args.IdentityValidationError.ShouldBeNull();
authenticator.LastUsername.ShouldBe("alice");
authenticator.LastPassword.ShouldBe("secret");
}
[Fact]
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
{
var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = "wrong" };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials"));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.Identity.ShouldBeNull();
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
}
[Fact]
public void HandleImpersonation_anonymous_token_falls_through_to_sdk_default()
{
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
// Handler leaves anonymous tokens untouched — no identity, no validation error.
args.Identity.ShouldBeNull();
args.IdentityValidationError.ShouldBeNull();
authenticator.LastUsername.ShouldBeNull("anonymous tokens must not hit the authenticator");
}
[Fact]
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
{
var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = "x" };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
args.Identity.ShouldBeNull();
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
}
[Fact]
public void HandleImpersonation_null_username_treated_as_empty_string()
{
var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = "abc" };
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
authenticator.LastUsername.ShouldBe(string.Empty);
}
[Fact]
public async Task NullOpcUaUserAuthenticator_always_denies()
{
var result = await NullOpcUaUserAuthenticator.Instance
.AuthenticateUserNameAsync("anyone", "anything", CancellationToken.None);
result.Success.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Roles.ShouldBeEmpty();
}
private sealed class RecordingAuthenticator(OpcUaUserAuthResult outcome) : IOpcUaUserAuthenticator
{
public string? LastUsername { get; private set; }
public string? LastPassword { get; private set; }
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
LastUsername = username;
LastPassword = password;
return Task.FromResult(outcome);
}
}
private sealed class ThrowingAuthenticator(Exception ex) : IOpcUaUserAuthenticator
{
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
=> Task.FromException<OpcUaUserAuthResult>(ex);
}
}
@@ -0,0 +1,157 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// F13b — verifies <see cref="OpcUaApplicationHost"/> publishes one
/// <see cref="ServerSecurityPolicy"/> per <see cref="OpcUaSecurityProfile"/> and emits both
/// Anonymous and UserName <see cref="UserTokenPolicy"/> entries. The pure-builder tests run
/// cross-platform without touching disk; the boot-verify test reuses the F13a PKI pattern.
/// </summary>
public sealed class OpcUaApplicationHostSecurityTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
[Fact]
public void BuildSecurityPolicies_default_set_emits_all_three_baseline_profiles()
{
var policies = OpcUaApplicationHost.BuildSecurityPolicies(new[]
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
}).ToList();
policies.Count.ShouldBe(3);
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
policies[1].SecurityMode.ShouldBe(MessageSecurityMode.Sign);
policies[1].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
policies[2].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
policies[2].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
[Fact]
public void BuildSecurityPolicies_dedupes_repeated_profiles()
{
var policies = OpcUaApplicationHost.BuildSecurityPolicies(new[]
{
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
OpcUaSecurityProfile.None,
}).ToList();
policies.Count.ShouldBe(2);
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
policies[1].SecurityMode.ShouldBe(MessageSecurityMode.None);
}
[Fact]
public void BuildSecurityPolicies_empty_input_falls_back_to_none()
{
var policies = OpcUaApplicationHost.BuildSecurityPolicies(Array.Empty<OpcUaSecurityProfile>()).ToList();
policies.Count.ShouldBe(1);
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
}
[Fact]
public void BuildUserTokenPolicies_emits_anonymous_and_username()
{
var tokens = OpcUaApplicationHost.BuildUserTokenPolicies().ToList();
tokens.Count.ShouldBe(2);
tokens.ShouldContain(t => t.TokenType == UserTokenType.Anonymous && t.PolicyId == "anonymous");
var userName = tokens.Single(t => t.TokenType == UserTokenType.UserName);
userName.PolicyId.ShouldBe("username_basic256sha256");
userName.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
[Fact]
public async Task StartAsync_populates_ServerConfiguration_with_all_enabled_profiles()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.SecAll",
ApplicationUri = $"urn:OtOpcUa.SecAll:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
EnabledSecurityProfiles =
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
},
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(new StandardServer(), Ct);
var config = host.ApplicationInstance!.ApplicationConfiguration;
config.ServerConfiguration.SecurityPolicies.Count.ShouldBe(3);
config.ServerConfiguration.UserTokenPolicies.Count.ShouldBe(2);
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeTrue();
var modes = config.ServerConfiguration.SecurityPolicies
.Select(p => p.SecurityMode)
.OrderBy(m => (int)m)
.ToArray();
modes.ShouldBe(new[] { MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt });
}
[Fact]
public async Task StartAsync_with_only_signandencrypt_omits_None_endpoint()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.SecHardened",
ApplicationUri = $"urn:OtOpcUa.SecHardened:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt },
AutoAcceptUntrustedClientCertificates = false,
},
NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(new StandardServer(), Ct);
var policies = host.ApplicationInstance!.ApplicationConfiguration.ServerConfiguration.SecurityPolicies;
policies.Count.ShouldBe(1);
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
host.ApplicationInstance.ApplicationConfiguration.SecurityConfiguration
.AutoAcceptUntrustedCertificates.ShouldBeFalse();
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}
@@ -0,0 +1,135 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// #85 — verifies <see cref="Phase7Applier.MaterialiseHierarchy"/> builds the UNS
/// Area/Line/Equipment folder tree through <see cref="IOpcUaAddressSpaceSink.EnsureFolder"/>.
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
/// drives a real <see cref="OtOpcUaNodeManager"/> and inspects the resulting predefined-node
/// count to prove the folders land in the SDK address space.
/// </summary>
public sealed class Phase7ApplierHierarchyTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
[Fact]
public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
{
var sink = new RecordingFolderSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
applier.MaterialiseHierarchy(composition);
var calls = sink.Calls;
calls.Count.ShouldBe(3);
calls[0].ShouldBe(("area-1", null, "Plant North"));
calls[1].ShouldBe(("line-1", "area-1", "Cell A"));
calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
}
[Fact]
public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
{
var sink = new RecordingFolderSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
applier.MaterialiseHierarchy(composition);
sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
}
[Fact]
public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
{
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Hierarchy",
ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var sdkServer = new OtOpcUaSdkServer();
await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull();
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
applier.MaterialiseHierarchy(new Phase7CompositionResult(
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
// Idempotent: re-applying with the same composition doesn't create duplicates.
applier.MaterialiseHierarchy(new Phase7CompositionResult(
UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort */ }
}
}
private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
{
private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
public void RebuildAddressSpace() { }
}
}
@@ -125,13 +125,17 @@ public sealed class Phase7ApplierTests
private sealed class RecordingSink : IOpcUaAddressSpaceSink private sealed class RecordingSink : IOpcUaAddressSpaceSink
{ {
public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new(); public ConcurrentQueue<(string NodeId, bool Active, bool Acknowledged)> AlarmQueue { get; } = new();
public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
public int RebuildCalls; public int RebuildCalls;
public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList(); public List<(string NodeId, bool Active, bool Acknowledged)> AlarmWrites => AlarmQueue.ToList();
public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> AlarmQueue.Enqueue((alarmNodeId, active, acknowledged)); => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged));
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
} }
@@ -145,6 +149,7 @@ public sealed class Phase7ApplierTests
{ {
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
} }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
} }
} }
@@ -0,0 +1,94 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// #81 residual — verifies <see cref="SdkServiceLevelPublisher"/> locates the standard
/// <c>VariableIds.Server_ServiceLevel</c> node through the SDK's DiagnosticsNodeManager and
/// writes the byte value. Boots a real <see cref="StandardServer"/> on a free port so the
/// SDK populates its predefined diagnostics nodes — that's what production sees.
/// </summary>
public sealed class SdkServiceLevelPublisherTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-pki-{Guid.NewGuid():N}");
[Fact]
public async Task Publish_writes_value_to_Server_ServiceLevel_variable()
{
var server = new StandardServer();
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.SvcLevel",
ApplicationUri = $"urn:OtOpcUa.SvcLevel:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, Ct);
var publisher = new SdkServiceLevelPublisher(
server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance);
publisher.Publish(200);
var variable = server.CurrentInstance.ServerObject.ServiceLevel;
variable.ShouldNotBeNull("Server.ServiceLevel must be present in the address space");
variable.Value.ShouldBe((byte)200);
}
[Fact]
public async Task Publish_is_idempotent_when_called_multiple_times()
{
var server = new StandardServer();
await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.SvcLevel.Idem",
ApplicationUri = $"urn:OtOpcUa.SvcLevel.Idem:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, Ct);
var publisher = new SdkServiceLevelPublisher(
server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance);
publisher.Publish(100);
publisher.Publish(150);
publisher.Publish(240);
server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort */ }
}
}
}
@@ -0,0 +1,178 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Observability;
/// <summary>
/// F13d — verifies the instrumentation sites actually emit on the central
/// <see cref="OtOpcUaTelemetry"/> meter + activity source. Each test attaches a one-shot
/// listener, exercises the instrumented path, then asserts the recorded measurement matches.
/// </summary>
public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
{
[Fact]
public void VirtualTagActor_evaluation_emits_otopcua_virtualtag_eval_counter()
{
using var recorder = new MeterRecorder("otopcua.virtualtag.eval");
var parent = CreateTestProbe();
var evaluator = new ConstEval(42);
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-tel-1", "expr", evaluator: evaluator));
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
recorder.WithTag("outcome", "ok").ShouldBeGreaterThanOrEqualTo(1);
}
[Fact]
public void OpcUaPublishActor_AttributeValueUpdate_emits_sink_write_counter()
{
using var recorder = new MeterRecorder("otopcua.opcua.sink.write");
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink,
serviceLevel: NullServiceLevelPublisher.Instance,
subscribeRedundancyTopic: false,
localNode: Commons.Types.NodeId.Parse("test-node")));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
NodeId: "ns=2;s=tag-1",
Value: 42,
Quality: OpcUaQuality.Good,
TimestampUtc: DateTime.UtcNow));
AwaitAssertion(() =>
{
recorder.Total.ShouldBeGreaterThanOrEqualTo(1);
recorder.WithTag("kind", "value").ShouldBeGreaterThanOrEqualTo(1);
});
}
[Fact]
public void RebuildAddressSpace_starts_an_address_space_rebuild_span()
{
using var spanRecorder = new ActivityRecorder("otopcua.opcua.address_space_rebuild");
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
sink: sink,
serviceLevel: NullServiceLevelPublisher.Instance,
subscribeRedundancyTopic: false,
localNode: Commons.Types.NodeId.Parse("test-node")));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(Commons.Types.CorrelationId.NewId()));
AwaitAssertion(() => spanRecorder.Activities.ShouldContain(a => a.OperationName == "otopcua.opcua.address_space_rebuild"));
}
private void AwaitAssertion(Action assertion)
{
var deadline = DateTime.UtcNow.AddSeconds(2);
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
try { assertion(); return; }
catch (Exception ex) { last = ex; Thread.Sleep(25); }
}
if (last is not null) throw last;
}
/// <summary>Listens to a single instrument by name and tallies the values + tags.</summary>
private sealed class MeterRecorder : IDisposable
{
private readonly string _name;
private readonly MeterListener _listener;
private long _total;
private readonly List<KeyValuePair<string, object?>[]> _tagSets = new();
private readonly object _gate = new();
public MeterRecorder(string instrumentName)
{
_name = instrumentName;
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OtOpcUaTelemetry.MeterName && instrument.Name == _name)
listener.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
lock (_gate)
{
_total += value;
_tagSets.Add(tags.ToArray());
}
});
_listener.Start();
}
public long Total { get { lock (_gate) return _total; } }
public int WithTag(string key, string value)
{
lock (_gate)
{
return _tagSets.Count(set => set.Any(t => t.Key == key && Equals(t.Value, value)));
}
}
public void Dispose() => _listener.Dispose();
}
/// <summary>Listens to a single ActivitySource by name and stores started Activities.</summary>
private sealed class ActivityRecorder : IDisposable
{
private readonly string _operationName;
private readonly ActivityListener _listener;
private readonly List<Activity> _activities = new();
private readonly object _gate = new();
public ActivityRecorder(string operationName)
{
_operationName = operationName;
_listener = new ActivityListener
{
ShouldListenTo = source => source.Name == OtOpcUaTelemetry.ActivitySourceName,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
ActivityStarted = activity =>
{
if (activity.OperationName == _operationName)
{
lock (_gate) _activities.Add(activity);
}
}
};
ActivitySource.AddActivityListener(_listener);
}
public IReadOnlyList<Activity> Activities { get { lock (_gate) return _activities.ToArray(); } }
public void Dispose() => _listener.Dispose();
}
private sealed class ConstEval(object? value) : IVirtualTagEvaluator
{
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.Ok(value);
}
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
public int Writes { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++;
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() { /* recorded via span */ }
}
}
@@ -139,6 +139,8 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
=> Calls.Enqueue($"WV:{nodeId}"); => Calls.Enqueue($"WV:{nodeId}");
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts)
=> Calls.Enqueue($"WA:{alarmNodeId}"); => Calls.Enqueue($"WA:{alarmNodeId}");
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> Calls.Enqueue($"EF:{folderNodeId}");
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
} }
} }
@@ -152,6 +152,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) => public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) =>
AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts)); AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts));
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
} }
@@ -0,0 +1,147 @@
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
/// <summary>
/// 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="StandardServer"/>,
/// wires <see cref="SdkServiceLevelPublisher"/> into a <see cref="DeferredServiceLevelPublisher"/>
/// (the production binding pattern), spawns <see cref="OpcUaPublishActor"/> against the
/// deferred publisher, and sends a <see cref="RedundancyStateChanged"/> snapshot. Asserts
/// <c>ServerObject.ServiceLevel.Value</c> reflects the role-derived byte.
/// </summary>
public sealed class ServiceLevelEndToEndTests : RuntimeActorTestBase
{
private static CancellationToken Ct => CancellationToken.None;
[Fact]
public async Task Primary_leader_drives_Server_ServiceLevel_to_240()
{
var pkiRoot = AllocatePkiRoot();
try
{
var server = new StandardServer();
await using var host = new OpcUaApplicationHost(
BuildOptions("PrimaryLeader", pkiRoot),
NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, Ct);
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(new SdkServiceLevelPublisher(
server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
var localNode = NodeId.Parse("node-A");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: deferred,
subscribeRedundancyTopic: false,
localNode: localNode));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(localNode, RedundancyRole.Primary, IsClusterLeader: true, IsRoleLeaderForDriver: true, AsOfUtc: DateTime.UtcNow),
},
CorrelationId: CorrelationId.NewId()));
AwaitAssertion(() =>
server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240));
}
finally
{
DeletePkiRoot(pkiRoot);
}
}
[Fact]
public async Task Secondary_drives_Server_ServiceLevel_to_100()
{
var pkiRoot = AllocatePkiRoot();
try
{
var server = new StandardServer();
await using var host = new OpcUaApplicationHost(
BuildOptions("Secondary", pkiRoot),
NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, Ct);
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(new SdkServiceLevelPublisher(
server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
var localNode = NodeId.Parse("node-B");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: deferred,
subscribeRedundancyTopic: false,
localNode: localNode));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(localNode, RedundancyRole.Secondary, IsClusterLeader: false, IsRoleLeaderForDriver: false, AsOfUtc: DateTime.UtcNow),
},
CorrelationId: CorrelationId.NewId()));
AwaitAssertion(() =>
server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)100));
}
finally
{
DeletePkiRoot(pkiRoot);
}
}
private static OpcUaApplicationHostOptions BuildOptions(string name, string pkiRoot) =>
new()
{
ApplicationName = $"OtOpcUa.E2E.{name}",
ApplicationUri = $"urn:OtOpcUa.E2E.{name}:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = pkiRoot,
};
private static string AllocatePkiRoot() =>
Path.Combine(Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}");
private static void DeletePkiRoot(string root)
{
if (Directory.Exists(root))
{
try { Directory.Delete(root, recursive: true); }
catch { /* best-effort */ }
}
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private void AwaitAssertion(Action assertion)
{
var deadline = DateTime.UtcNow.AddSeconds(3);
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
try { assertion(); return; }
catch (Exception ex) { last = ex; Thread.Sleep(30); }
}
if (last is not null) throw last;
}
}