Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
T
Joseph Doherty 6134050ceb fix(server): resolve Low code-review findings (Server-004,006,008,012,014,015)
- Server-004: pass the role-derived display name to UserIdentity's base
  ctor (the SDK's DisplayName has no public setter) and drop the dead
  Display property; make RoleBasedIdentity internal sealed.
- Server-006: derive a bounded CancellationToken from the SDK's
  OperationContext.OperationDeadline in OnReadValue / OnWriteValue so a
  stalled driver call can no longer pin the request thread.
- Server-008: mark handled slots via CallMethodRequest.Processed = true
  in RouteScriptedAlarmMethodCalls (the SDK skips on Processed, not on a
  Good error slot).
- Server-012: PeerHttpProbeLoop.ProbeAsync stops mutating client.Timeout
  per call; uses a per-request CancellationTokenSource linked to the
  shutdown token instead.
- Server-014: wire SealedBootstrap into Program.cs via AddSealedBootstrap
  + OpcUaServerService so the generation-sealed cache + stale-config flag
  + resilient reader actually run; /healthz now reflects cache-fallback
  state.
- Server-015: replace the stale 'PR 16 / PR 17 minimum-viable scope'
  class summaries on OtOpcUaServer and OpcUaServerOptions with the
  shipped LDAP + anonymous-role + configurable security-profile prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:24:20 -04:00

128 lines
6.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server;
/// <summary>
/// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf).
/// Bootstraps config, starts the <see cref="DriverHost"/>, starts the OPC UA server via
/// <see cref="OpcUaApplicationHost"/>, drives each driver's discovery into the address space,
/// runs until stopped.
/// </summary>
public sealed class OpcUaServerService(
// Server-014 — consume SealedBootstrap so the Phase 6.1 Stream D resilient-reader +
// GenerationSealedCache + StaleConfigFlag chain actually runs on every boot. The
// legacy NodeBootstrap is still registered for integration tests that construct it
// directly, but the production service uses the generation-sealed path.
SealedBootstrap bootstrap,
DriverHost driverHost,
OpcUaApplicationHost applicationHost,
DriverEquipmentContentRegistry equipmentContentRegistry,
DriverInstanceBootstrapper driverBootstrapper,
Phase7Composer phase7Composer,
AuthorizationBootstrap authorizationBootstrap,
IServiceScopeFactory scopeFactory,
ILogger<OpcUaServerService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("OtOpcUa.Server starting");
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
// ADR-001 Option A — populate per-driver Equipment namespace snapshots into the
// registry before StartAsync walks the address space. The walker on the OPC UA side
// reads synchronously from the registry; pre-loading here means the hot path stays
// non-blocking + each driver pays at most one Config-DB query at bootstrap time.
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
// address space until the first publish, then the registry fills on next restart.
if (result.GenerationId is { } gen)
{
// Task #248 — register IDriver instances from the published DriverInstance
// rows BEFORE the equipment-content load + Phase 7 compose, so the rest of
// the pipeline sees a populated DriverHost. Without this step Phase 7's
// CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read
// BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke).
await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken);
await PopulateEquipmentContentAsync(gen, stoppingToken);
// Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows,
// compose VirtualTagEngine + ScriptedAlarmEngine, start the driver-bridge
// feed. SetPhase7Sources MUST run before applicationHost.StartAsync because
// OtOpcUaServer + DriverNodeManager construction captures the field values
// — late binding after server start is rejected with InvalidOperationException.
// No-op when the generation has no virtual tags or scripted alarms.
var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken);
applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable,
phase7.AlarmEngine);
// Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver
// from the published generation's NodeAcl rows and the populated equipment
// registry. No-op when Node:Authorization:Enabled=false. Must run before
// StartAsync: OtOpcUaServer + DriverNodeManager construction captures the
// field values on the application host.
var (authzGate, scopeResolver) = await authorizationBootstrap
.BuildAsync(gen, stoppingToken).ConfigureAwait(false);
applicationHost.SetAuthorization(authzGate, scopeResolver);
}
await applicationHost.StartAsync(stoppingToken);
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
}
catch (OperationCanceledException)
{
logger.LogInformation("OtOpcUa.Server stopping");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
// Dispose Phase 7 first so the bridge stops feeding the cache + the engines
// stop firing alarm/historian events before the OPC UA server tears down its
// node managers. Otherwise an in-flight cascade could try to push through a
// disposed source and surface as a noisy shutdown warning.
await phase7Composer.DisposeAsync();
await applicationHost.DisposeAsync();
await driverHost.DisposeAsync();
}
/// <summary>
/// Pre-load an <c>EquipmentNamespaceContent</c> snapshot for each registered driver at
/// the bootstrapped generation. Null results (driver has no Equipment rows —
/// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116#121) are skipped: the walker
/// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it.
/// Opens one scope so the scoped <c>OtOpcUaConfigDbContext</c> is shared across all
/// per-driver queries rather than paying scope-setup overhead per driver.
/// </summary>
private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var loader = scope.ServiceProvider.GetRequiredService<EquipmentNamespaceContentLoader>();
var loaded = 0;
foreach (var driverId in driverHost.RegisteredDriverIds)
{
var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false);
if (content is null) continue;
equipmentContentRegistry.Set(driverId, content);
loaded++;
}
logger.LogInformation(
"Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}",
loaded, driverHost.RegisteredDriverIds.Count, generationId);
}
}