Phase 3 PR 17 — complete OPC UA server startup + live integration test #16

Merged
dohertj2 merged 1 commits from phase-3-pr17-server-startup into v2 2026-04-18 08:28:43 -04:00
7 changed files with 410 additions and 5 deletions

View File

@@ -29,6 +29,9 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private readonly IWritable? _writable;
private readonly ILogger<DriverNodeManager> _logger;
/// <summary>The driver whose address space this node manager exposes.</summary>
public IDriver Driver => _driver;
private FolderState? _driverRoot;
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);

View File

@@ -0,0 +1,181 @@
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// Wraps <see cref="ApplicationInstance"/> to bring the OPC UA server online — builds an
/// <see cref="ApplicationConfiguration"/> programmatically (no external XML file), ensures
/// the application certificate exists in the PKI store (auto-generates self-signed on first
/// run), starts the server, then walks each <see cref="DriverNodeManager"/> and invokes
/// <see cref="GenericDriverNodeManager.BuildAddressSpaceAsync"/> against it so the driver's
/// discovery streams into the already-running server's address space.
/// </summary>
public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaServerOptions _options;
private readonly DriverHost _driverHost;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
private OtOpcUaServer? _server;
private bool _disposed;
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger)
{
_options = options;
_driverHost = driverHost;
_loggerFactory = loggerFactory;
_logger = logger;
}
public OtOpcUaServer? Server => _server;
/// <summary>
/// Builds the <see cref="ApplicationConfiguration"/>, validates/creates the application
/// certificate, constructs + starts the <see cref="OtOpcUaServer"/>, then drives
/// <see cref="GenericDriverNodeManager.BuildAddressSpaceAsync"/> per registered driver so
/// the address space is populated before the first client connects.
/// </summary>
public async Task StartAsync(CancellationToken ct)
{
_application = new ApplicationInstance
{
ApplicationName = _options.ApplicationName,
ApplicationType = ApplicationType.Server,
ApplicationConfiguration = BuildConfiguration(),
};
var hasCert = await _application.CheckApplicationInstanceCertificate(silent: true, minimumKeySize: CertificateFactory.DefaultKeySize).ConfigureAwait(false);
if (!hasCert)
throw new InvalidOperationException(
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
_server = new OtOpcUaServer(_driverHost, _loggerFactory);
await _application.Start(_server).ConfigureAwait(false);
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
_options.EndpointUrl, _server.DriverNodeManagers.Count);
// Drive each driver's discovery through its node manager. The node manager IS the
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
// its internal map and wires OnAlarmEvent → sink routing.
foreach (var nodeManager in _server.DriverNodeManagers)
{
var driverId = nodeManager.Driver.DriverInstanceId;
try
{
var generic = new GenericDriverNodeManager(nodeManager.Driver);
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
}
catch (Exception ex)
{
// Per decision #12: driver exceptions isolate — log and keep the server serving
// the other drivers' subtrees. Re-building this one takes a Reinitialize call.
_logger.LogError(ex, "Discovery failed for driver {Driver}; subtree faulted", driverId);
}
}
}
private ApplicationConfiguration BuildConfiguration()
{
Directory.CreateDirectory(_options.PkiStoreRoot);
var cfg = new ApplicationConfiguration
{
ApplicationName = _options.ApplicationName,
ApplicationUri = _options.ApplicationUri,
ApplicationType = ApplicationType.Server,
ProductUri = "urn:OtOpcUa:Server",
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
SubjectName = "CN=" + _options.ApplicationName,
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_options.PkiStoreRoot, "issuers"),
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_options.PkiStoreRoot, "trusted"),
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_options.PkiStoreRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ServerConfiguration = new ServerConfiguration
{
BaseAddresses = new StringCollection { _options.EndpointUrl },
SecurityPolicies = new ServerSecurityPolicyCollection
{
new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
},
UserTokenPolicies = new UserTokenPolicyCollection
{
new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "Anonymous",
SecurityPolicyUri = SecurityPolicies.None,
},
},
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
},
TraceConfiguration = new TraceConfiguration(),
};
cfg.Validate(ApplicationType.Server).GetAwaiter().GetResult();
if (cfg.SecurityConfiguration.AutoAcceptUntrustedCertificates)
{
cfg.CertificateValidator.CertificateValidation += (_, e) =>
{
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
e.Accept = true;
};
}
return cfg;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try
{
_server?.Stop();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OPC UA server stop threw during dispose");
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// OPC UA server endpoint + application-identity configuration. Bound from the
/// <c>OpcUaServer</c> section of <c>appsettings.json</c>. PR 17 minimum-viable scope: no LDAP,
/// no security profiles beyond None — those wire in alongside a future deployment-policy PR
/// that reads from the central config DB instead of appsettings.
/// </summary>
public sealed class OpcUaServerOptions
{
public const string SectionName = "OpcUaServer";
/// <summary>
/// Fully-qualified endpoint URI clients connect to. Use <c>0.0.0.0</c> to bind all
/// interfaces; the stack rewrites to the machine's hostname for the returned endpoint
/// description at GetEndpoints time.
/// </summary>
public string EndpointUrl { get; init; } = "opc.tcp://0.0.0.0:4840/OtOpcUa";
/// <summary>Human-readable application name surfaced in the endpoint description.</summary>
public string ApplicationName { get; init; } = "OtOpcUa Server";
/// <summary>Stable application URI — must match the subjectAltName of the app cert.</summary>
public string ApplicationUri { get; init; } = "urn:OtOpcUa:Server";
/// <summary>
/// Directory where the OPC UA stack stores the application certificate + trusted /
/// rejected cert folders. Defaults to <c>%ProgramData%\OtOpcUa\pki</c>; the stack
/// creates the directory tree on first run and generates a self-signed cert.
/// </summary>
public string PkiStoreRoot { get; init; } =
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"OtOpcUa", "pki");
/// <summary>
/// When true, the stack auto-trusts client certs on first connect. Dev-default = true,
/// production deployments should flip this to false and manually trust clients via the
/// Admin UI.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; init; } = true;
}

View File

@@ -1,18 +1,20 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
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"/>, and runs until stopped.
/// Phase 1 scope: bootstrap-only — the OPC UA transport layer that serves endpoints stays in
/// the legacy Host until the Phase 2 cutover.
/// 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(
NodeBootstrap bootstrap,
DriverHost driverHost,
OpcUaApplicationHost applicationHost,
ILogger<OpcUaServerService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -22,8 +24,11 @@ public sealed class OpcUaServerService(
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
// Phase 1: no drivers are wired up at bootstrap — Galaxy still lives in legacy Host.
// Phase 2 will register drivers here based on the fetched generation.
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
// extension once the central config DB query + per-driver factory land; for now the
// server comes up with whatever drivers are in DriverHost at start time.
await applicationHost.StartAsync(stoppingToken);
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
@@ -40,6 +45,7 @@ public sealed class OpcUaServerService(
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await applicationHost.DisposeAsync();
await driverHost.DisposeAsync();
}
}

View File

@@ -5,6 +5,7 @@ using Serilog;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
var builder = Host.CreateApplicationBuilder(args);
@@ -29,10 +30,23 @@ var options = new NodeOptions
LocalCachePath = nodeSection.GetValue<string>("LocalCachePath") ?? "config_cache.db",
};
var opcUaSection = builder.Configuration.GetSection(OpcUaServerOptions.SectionName);
var opcUaOptions = new OpcUaServerOptions
{
EndpointUrl = opcUaSection.GetValue<string>("EndpointUrl") ?? "opc.tcp://0.0.0.0:4840/OtOpcUa",
ApplicationName = opcUaSection.GetValue<string>("ApplicationName") ?? "OtOpcUa Server",
ApplicationUri = opcUaSection.GetValue<string>("ApplicationUri") ?? "urn:OtOpcUa:Server",
PkiStoreRoot = opcUaSection.GetValue<string>("PkiStoreRoot")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OtOpcUa", "pki"),
AutoAcceptUntrustedClientCertificates = opcUaSection.GetValue<bool?>("AutoAcceptUntrustedClientCertificates") ?? true,
};
builder.Services.AddSingleton(options);
builder.Services.AddSingleton(opcUaOptions);
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
builder.Services.AddSingleton<DriverHost>();
builder.Services.AddSingleton<NodeBootstrap>();
builder.Services.AddSingleton<OpcUaApplicationHost>();
builder.Services.AddHostedService<OpcUaServerService>();
var host = builder.Build();

View File

@@ -0,0 +1,158 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Integration")]
public sealed class OpcUaServerIntegrationTests : IAsyncLifetime
{
// Use a non-default port + per-test-run PKI root to avoid colliding with anything else
// running on the box (a live v1 Host or a developer's previous run).
private static readonly int Port = 48400 + Random.Shared.Next(0, 99);
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaTest";
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-test-{Guid.NewGuid():N}");
private DriverHost _driverHost = null!;
private OpcUaApplicationHost _server = null!;
private FakeDriver _driver = null!;
public async ValueTask InitializeAsync()
{
_driverHost = new DriverHost();
_driver = new FakeDriver();
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
var options = new OpcUaServerOptions
{
EndpointUrl = _endpoint,
ApplicationName = "OtOpcUaTest",
ApplicationUri = "urn:OtOpcUa:Server:Test",
PkiStoreRoot = _pkiRoot,
AutoAcceptUntrustedClientCertificates = true,
};
_server = new OpcUaApplicationHost(options, _driverHost, NullLoggerFactory.Instance,
NullLogger<OpcUaApplicationHost>.Instance);
await _server.StartAsync(CancellationToken.None);
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
await _driverHost.DisposeAsync();
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
}
[Fact]
public async Task Client_can_connect_and_browse_driver_subtree()
{
using var session = await OpenSessionAsync();
// Browse the driver subtree registered under ObjectsFolder. FakeDriver registers one
// folder ("TestFolder") with one variable ("Var1"), so we expect to see our driver's
// root folder plus standard UA children.
var rootRef = new NodeId("fake", (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake"));
session.Browse(null, null, rootRef, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var references);
references.Count.ShouldBeGreaterThan(0);
references.ShouldContain(r => r.BrowseName.Name == "TestFolder");
}
[Fact]
public async Task Client_can_read_a_driver_variable_through_the_node_manager()
{
using var session = await OpenSessionAsync();
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake");
var varNodeId = new NodeId("TestFolder.Var1", nsIndex);
var dv = session.ReadValue(varNodeId);
dv.ShouldNotBeNull();
// FakeDriver.ReadAsync returns 42 as the value.
dv.Value.ShouldBe(42);
}
private async Task<ISession> OpenSessionAsync()
{
var cfg = new ApplicationConfiguration
{
ApplicationName = "OtOpcUaTestClient",
ApplicationUri = "urn:OtOpcUa:TestClient",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(_pkiRoot, "client-own"),
SubjectName = "CN=OtOpcUaTestClient",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
AutoAcceptUntrustedCertificates = true,
AddAppCertToTrustedStore = true,
},
TransportConfigurations = new TransportConfigurationCollection(),
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
};
await cfg.Validate(ApplicationType.Client);
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
// Let the client fetch the live endpoint description from the running server so the
// UserTokenPolicy it signs with matches what the server actually advertised (including
// the PolicyId = "Anonymous" the server sets).
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
var endpointConfig = EndpointConfiguration.Create(cfg);
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaTestClientSession", 60000,
new UserIdentity(new AnonymousIdentityToken()), null);
}
/// <summary>
/// Minimum driver that implements enough of IDriver + ITagDiscovery + IReadable to drive
/// the integration test. Returns a single folder with one variable that reads as 42.
/// </summary>
private sealed class FakeDriver : IDriver, ITagDiscovery, IReadable
{
public string DriverInstanceId => "fake";
public string DriverType => "Fake";
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
var folder = builder.Folder("TestFolder", "TestFolder");
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
"TestFolder.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
return Task.CompletedTask;
}
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> result =
fullReferences.Select(_ => new DataValueSnapshot(42, 0u, now, now)).ToArray();
return Task.FromResult(result);
}
}
}

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>