Two complementary pieces that together unblock the last Phase 7 exit-gate deferrals. ## #239 — Thread virtual + scripted-alarm IReadable through to DriverNodeManager OtOpcUaServer gains virtualReadable + scriptedAlarmReadable ctor params; shared across every DriverNodeManager it materializes so reads from a virtual-tag node in any driver's subtree route to the same engine instance. Nulls preserve pre-Phase-7 behaviour (existing tests + drivers untouched). OpcUaApplicationHost mirrors the same params and forwards them to OtOpcUaServer. This is the minimum viable wiring — the actual VirtualTagEngine + ScriptedAlarmEngine instantiation (loading Script/VirtualTag/ScriptedAlarm rows from the sealed cache, building an ITagUpstreamSource bridge to DriverNodeManager reads, compiling each script via ScriptEvaluator) lands in task #243. Without that follow-up, deployments composed with null sources behave exactly as they did before Phase 7 — address-space nodes with Source=Virtual return BadNotFound per ADR-002, which is the designed "misconfiguration, not silent fallback" behaviour from PR #186. ## #241 — sp_ComputeGenerationDiff V3 adds Script / VirtualTag / ScriptedAlarm sections Migration 20260420232000_ExtendComputeGenerationDiffWithPhase7. Same CHECKSUM-based Modified detection the existing sections use. Logical ids: ScriptId / VirtualTagId / ScriptedAlarmId. Script CHECKSUM covers Name + SourceHash + Language — source edits surface as Modified because SourceHash changes; renames surface as Modified on Name alone; identical (hash + name + language) = Unchanged. VirtualTag + ScriptedAlarm CHECKSUMs cover their content columns. ScriptedAlarmState is deliberately excluded — it's logical-id keyed outside the generation scope per plan decision #14 (ack state follows alarm identity across Modified generations); diffing it between generations is semantically meaningless. Down() restores V2 (the NodeAcl-extended proc from migration 20260420000001). ## No new test count — both pieces are proven by existing suites The NodeSourceKind dispatch kernel is already covered by DriverNodeManagerSourceDispatchTests (PR #186). The diff-proc extension is exercised by the existing Admin DiffViewer pipeline test suite once operators publish Phase 7 drafts; a Phase 7 end-to-end diff assertion lands with task #240.
168 lines
7.5 KiB
C#
168 lines
7.5 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 AuthorizationGate? _authzGate;
|
|
private readonly NodeScopeResolver? _scopeResolver;
|
|
private readonly Func<string, DriverTier>? _tierLookup;
|
|
private readonly Func<string, string?>? _resilienceConfigLookup;
|
|
|
|
// Phase 7 Stream G follow-up wiring (task #239). Shared across every DriverNodeManager
|
|
// instantiated by this server so virtual-tag reads and scripted-alarm reads from any
|
|
// driver's address-space subtree route to the same engine. When null (no Phase 7
|
|
// engines composed for this deployment) DriverNodeManager falls back to driver-only
|
|
// dispatch — identical to pre-Phase-7 behaviour.
|
|
private readonly IReadable? _virtualReadable;
|
|
private readonly IReadable? _scriptedAlarmReadable;
|
|
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
|
|
|
public OtOpcUaServer(
|
|
DriverHost driverHost,
|
|
IUserAuthenticator authenticator,
|
|
DriverResiliencePipelineBuilder pipelineBuilder,
|
|
ILoggerFactory loggerFactory,
|
|
AuthorizationGate? authzGate = null,
|
|
NodeScopeResolver? scopeResolver = null,
|
|
Func<string, DriverTier>? tierLookup = null,
|
|
Func<string, string?>? resilienceConfigLookup = null,
|
|
IReadable? virtualReadable = null,
|
|
IReadable? scriptedAlarmReadable = null)
|
|
{
|
|
_driverHost = driverHost;
|
|
_authenticator = authenticator;
|
|
_pipelineBuilder = pipelineBuilder;
|
|
_authzGate = authzGate;
|
|
_scopeResolver = scopeResolver;
|
|
_tierLookup = tierLookup;
|
|
_resilienceConfigLookup = resilienceConfigLookup;
|
|
_virtualReadable = virtualReadable;
|
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
|
_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: tier comes from lookup (Phase 6.1 Stream B.1
|
|
// DriverTypeRegistry in the prod wire-up) or falls back to Tier A. ResilienceConfig
|
|
// JSON comes from the DriverInstance row via the optional lookup Func; parser
|
|
// layers JSON overrides on top of tier defaults (Phase 6.1 Stream A.2).
|
|
var tier = _tierLookup?.Invoke(driver.DriverType) ?? DriverTier.A;
|
|
var resilienceJson = _resilienceConfigLookup?.Invoke(driver.DriverInstanceId);
|
|
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceJson, out var diag);
|
|
if (diag is not null)
|
|
logger.LogWarning("ResilienceConfig parse diagnostic for driver {DriverId}: {Diag}", driver.DriverInstanceId, diag);
|
|
|
|
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
|
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
|
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
|
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
|
_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,
|
|
};
|
|
}
|