Closes Stream C per docs/v2/implementation/phase-6-1-resilience-and-observability.md. Core.Observability (new namespace): - DriverHealthReport — pure-function aggregation over DriverHealthSnapshot list. Empty fleet = Healthy. Any Faulted = Faulted. Any Unknown/Initializing (no Faulted) = NotReady. Any Degraded or Reconnecting (no Faulted, no NotReady) = Degraded. Else Healthy. HttpStatus(verdict) maps to the Stream C.1 state matrix: Healthy/Degraded → 200, NotReady/Faulted → 503. - LogContextEnricher — Serilog LogContext wrapper. Push(id, type, capability, correlationId) returns an IDisposable scope; inner log calls carry DriverInstanceId / DriverType / CapabilityName / CorrelationId structured properties automatically. NewCorrelationId = 12-hex-char GUID slice for cases where no OPC UA RequestHeader.RequestHandle is in flight. CapabilityInvoker — now threads LogContextEnricher around every ExecuteAsync / ExecuteWriteAsync call site. OtOpcUaServer passes driver.DriverType through so logs correlate to the driver type too. Every capability call emits structured fields per the Stream C.4 compliance check. Server.Observability: - HealthEndpointsHost — standalone HttpListener on http://localhost:4841/ (loopback avoids Windows URL-ACL elevation; remote probing via reverse proxy or explicit netsh urlacl grant). Routes: /healthz → 200 when (configDbReachable OR usingStaleConfig); 503 otherwise. Body: status, uptimeSeconds, configDbReachable, usingStaleConfig. /readyz → DriverHealthReport.Aggregate + HttpStatus mapping. Body: verdict, drivers[], degradedDrivers[], uptimeSeconds. anything else → 404. Disposal cooperative with the HttpListener shutdown. - OpcUaApplicationHost starts the health host after the OPC UA server comes up and disposes it on shutdown. New OpcUaServerOptions knobs: HealthEndpointsEnabled (default true), HealthEndpointsPrefix (default http://localhost:4841/). Program.cs: - Serilog pipeline adds Enrich.FromLogContext + opt-in JSON file sink via `Serilog:WriteJson = true` appsetting. Uses Serilog.Formatting.Compact's CompactJsonFormatter (one JSON object per line — SIEMs like Splunk, Datadog, Graylog ingest without a regex parser). Server.Tests: - Existing 3 OpcUaApplicationHost integration tests now set HealthEndpointsEnabled=false to avoid port :4841 collisions under parallel execution. - New HealthEndpointsHostTests (9): /healthz healthy empty fleet; stale-config returns 200 with flag; unreachable+no-cache returns 503; /readyz empty/ Healthy/Faulted/Degraded/Initializing drivers return correct status and bodies; unknown path → 404. Uses ephemeral ports via Interlocked counter. Core.Tests: - DriverHealthReportTests (8): empty fleet, all-healthy, any-Faulted trumps, any-NotReady without Faulted, Degraded without Faulted/NotReady, HttpStatus per-verdict theory. - LogContextEnricherTests (8): all 4 properties attach; scope disposes cleanly; NewCorrelationId shape; null/whitespace driverInstanceId throws. - CapabilityInvokerEnrichmentTests (2): inner logs carry structured properties; no context leak outside the call site. Full solution dotnet test: 1016 passing (baseline 906, +110 for Phase 6.1 so far across Streams A+B+C). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
5.6 KiB
C#
135 lines
5.6 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
|
|
/// <summary>
|
|
/// <see cref="StandardServer"/> subclass that wires one <see cref="DriverNodeManager"/> per
|
|
/// registered driver from <see cref="DriverHost"/>. Anonymous endpoint on
|
|
/// <c>opc.tcp://0.0.0.0:4840</c>, no security — PR 16 minimum-viable scope; LDAP + security
|
|
/// profiles are deferred to their own PR on top of this.
|
|
/// </summary>
|
|
public sealed class OtOpcUaServer : StandardServer
|
|
{
|
|
private readonly DriverHost _driverHost;
|
|
private readonly IUserAuthenticator _authenticator;
|
|
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
|
|
|
public OtOpcUaServer(
|
|
DriverHost driverHost,
|
|
IUserAuthenticator authenticator,
|
|
DriverResiliencePipelineBuilder pipelineBuilder,
|
|
ILoggerFactory loggerFactory)
|
|
{
|
|
_driverHost = driverHost;
|
|
_authenticator = authenticator;
|
|
_pipelineBuilder = pipelineBuilder;
|
|
_loggerFactory = loggerFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read-only snapshot of the driver node managers materialized at server start. Used by
|
|
/// the generic-driver-node-manager-driven discovery flow after the server starts — the
|
|
/// host walks each entry and invokes
|
|
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync(manager)</c> passing the manager
|
|
/// as its own <see cref="IAddressSpaceBuilder"/>.
|
|
/// </summary>
|
|
public IReadOnlyList<DriverNodeManager> DriverNodeManagers => _driverNodeManagers;
|
|
|
|
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
|
|
{
|
|
foreach (var driverId in _driverHost.RegisteredDriverIds)
|
|
{
|
|
var driver = _driverHost.GetDriver(driverId);
|
|
if (driver is null) continue;
|
|
|
|
var logger = _loggerFactory.CreateLogger<DriverNodeManager>();
|
|
// Per-driver resilience options: default Tier A pending Stream B.1 which wires
|
|
// per-type tiers into DriverTypeRegistry. Read ResilienceConfig JSON from the
|
|
// DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults.
|
|
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
|
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
|
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger);
|
|
_driverNodeManagers.Add(manager);
|
|
}
|
|
|
|
return new MasterNodeManager(server, configuration, null, _driverNodeManagers.ToArray());
|
|
}
|
|
|
|
protected override void OnServerStarted(IServerInternal server)
|
|
{
|
|
base.OnServerStarted(server);
|
|
// Hook UserName / Anonymous token validation here. Anonymous passes through; UserName
|
|
// is validated against the IUserAuthenticator (LDAP in production). Rejected identities
|
|
// throw ServiceResultException which the stack translates to Bad_IdentityTokenInvalid.
|
|
server.SessionManager.ImpersonateUser += OnImpersonateUser;
|
|
}
|
|
|
|
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
|
|
{
|
|
switch (args.NewIdentity)
|
|
{
|
|
case AnonymousIdentityToken:
|
|
args.Identity = new UserIdentity(); // anonymous
|
|
return;
|
|
|
|
case UserNameIdentityToken user:
|
|
{
|
|
var result = _authenticator.AuthenticateAsync(
|
|
user.UserName, user.DecryptedPassword, CancellationToken.None)
|
|
.GetAwaiter().GetResult();
|
|
if (!result.Success)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadUserAccessDenied,
|
|
"Invalid username or password ({0})", result.Error ?? "no detail");
|
|
}
|
|
args.Identity = new RoleBasedIdentity(user.UserName, result.DisplayName, result.Roles);
|
|
return;
|
|
}
|
|
|
|
default:
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadIdentityTokenInvalid,
|
|
"Unsupported user identity token type: {0}", args.NewIdentity?.GetType().Name ?? "null");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tiny UserIdentity carrier that preserves the resolved roles so downstream node
|
|
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
|
/// uses the stack's default.
|
|
/// </summary>
|
|
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
|
|
{
|
|
public IReadOnlyList<string> Roles { get; }
|
|
public string? Display { get; }
|
|
|
|
public RoleBasedIdentity(string userName, string? displayName, IReadOnlyList<string> roles)
|
|
: base(userName, "")
|
|
{
|
|
Display = displayName;
|
|
Roles = roles;
|
|
}
|
|
}
|
|
|
|
protected override ServerProperties LoadServerProperties() => new()
|
|
{
|
|
ManufacturerName = "OtOpcUa",
|
|
ProductName = "OtOpcUa.Server",
|
|
ProductUri = "urn:OtOpcUa:Server",
|
|
SoftwareVersion = "2.0.0",
|
|
BuildNumber = "0",
|
|
BuildDate = DateTime.UtcNow,
|
|
};
|
|
}
|