mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
Allow passing an existing config XML to the hosted server DI AddServer (#4325)
# Description Extends the hosted-server dependency-injection surface so applications that already own a classic `ApplicationConfiguration` XML file (`*.Config.xml`) have a direct migration path to DI with **all** of their existing settings applied. New API surface: - `AddServer(string configurationFile, Action<ApplicationConfiguration>? configure = null)` and the `AddServer<TServer>` variant load the configuration from the file instead of building it from `OpcUaServerOptions`. - `AddServer(Stream configurationStream, ...)` (plus generic variant) for documents that are not files on disk, e.g. embedded resources. The stream is read once and disposed by the hosted service during startup. - `OpcUaServerOptions.ConfigurationFile` / `ConfigurationStream` back the overloads, so the options-callback route works too and the file path can be bound from configuration (`OpcUa:Server:ConfigurationFile`). - `OpcUaServerOptions.ConfigureLoadedConfiguration` runs after the document is loaded and validated but before certificates are checked, so individual settings can be overridden from code without editing the file. Behavior: - The document is loaded through `ApplicationInstance.LoadApplicationConfigurationAsync`, so every setting (base addresses, security policies, certificate stores, transport quotas, operation limits, user token policies) applies exactly as on the classic path, including localhost fix-up. - The supplied document is authoritative: the builder-feeding options (`ApplicationName`, `EndpointUrls`, `PkiRoot`, policy toggles, `ConfigureBuilder`, ...) are not applied on this path, and it takes precedence over a shared application registered with `ConfigureApplication(...)`. Options acting on the hosted server itself (`Identity`, `ConfigureRateLimits`) and all fluent registrations (`AddNodeManager`, `ConfigureRoles`, identity authenticators, ...) compose unchanged. - DI-registered `ICertificateManager` / `ICertificatePasswordProvider` are honored, the "listening at" log reports the loaded base addresses, and the unmatched user-token-policy startup warning is derived from the policies the document advertises. - Setting both `ConfigurationFile` and `ConfigurationStream` fails startup with a clear `InvalidOperationException`; a missing file fails startup with the original `ServiceResultException`. Coverage: new `ConfigurationFileHostingTests` fixture (12 tests) with end-to-end hosted starts from file and stream asserting the loaded settings, custom `TServer` types, the override callback, precedence over `ConfigureApplication`, warning derivation from the document, missing-file and file+stream startup failures, argument validation, the duplicate-registration guard, and configuration-section binding. Docs: new "Migrating with an existing configuration XML file" section in `docs/DependencyInjection.md` (plus first-class options table rows), a pointer in the 2.0 migration guide, and a NuGet README mention. ## Related Issues None — additive convenience overloads on the existing hosted-server dependency-injection surface; no behavior change for existing registrations. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -403,6 +403,78 @@ an anonymous authenticator matching its default anonymous user-token policy.
|
||||
If a non-anonymous user-token policy is configured without a corresponding
|
||||
authenticator, startup logs a warning.
|
||||
|
||||
### Migrating with an existing configuration XML file
|
||||
|
||||
Applications that already own a classic OPC UA application configuration
|
||||
file (`*.Config.xml`, previously loaded through
|
||||
`ApplicationInstance.LoadApplicationConfigurationAsync`) can pass that
|
||||
file to `AddServer` directly and keep every setting in it — base
|
||||
addresses, security policies, certificate stores, transport quotas,
|
||||
operation limits, user-token policies — while adopting the hosted
|
||||
dependency-injection surface:
|
||||
|
||||
```csharp
|
||||
builder.Services
|
||||
.AddOpcUa()
|
||||
.AddServer("MyServer.Config.xml")
|
||||
.AddNodeManager<MyNodeManagerFactory>();
|
||||
```
|
||||
|
||||
The same file can be referenced from configuration instead
|
||||
(`OpcUa:Server:ConfigurationFile`), and a custom `StandardServer`
|
||||
subclass works too:
|
||||
|
||||
```json
|
||||
{ "OpcUa": { "Server": { "ConfigurationFile": "MyServer.Config.xml" } } }
|
||||
```
|
||||
|
||||
```csharp
|
||||
builder.Services.AddOpcUa().AddServer<MyServer>("MyServer.Config.xml");
|
||||
```
|
||||
|
||||
On this path the file is authoritative: the `OpcUaServerOptions` knobs
|
||||
that feed the configuration builder (`ApplicationName`, `EndpointUrls`,
|
||||
`PkiRoot`, policy toggles, transport quotas, `ConfigureBuilder`, ...)
|
||||
are not applied, and the file also takes precedence over a shared
|
||||
application registered with `ConfigureApplication(...)`. Options that
|
||||
act on the hosted server itself (`Identity`, `ConfigureRateLimits`) and
|
||||
all fluent registrations (`AddNodeManager`, `ConfigureRoles`,
|
||||
`AddDefaultIdentityAuthenticators`, ...) keep working. To override
|
||||
individual file settings from code, pass the optional callback, which
|
||||
runs after the file is loaded and validated but before certificates are
|
||||
checked and the server starts:
|
||||
|
||||
```csharp
|
||||
builder.Services
|
||||
.AddOpcUa()
|
||||
.AddServer("MyServer.Config.xml", configuration =>
|
||||
{
|
||||
configuration.ServerConfiguration.MaxSessionCount = 25;
|
||||
});
|
||||
```
|
||||
|
||||
A configuration document that is not a file on disk — an embedded
|
||||
resource, a document fetched from a store — can be supplied as a
|
||||
`Stream` instead (`OpcUaServerOptions.ConfigurationStream`; mutually
|
||||
exclusive with `ConfigurationFile`). The stream must remain open until
|
||||
the host starts the server; it is read once and disposed by the hosted
|
||||
service during startup, so do not wrap it in a `using` yourself:
|
||||
|
||||
```csharp
|
||||
Stream stream = typeof(Program).Assembly
|
||||
.GetManifestResourceStream("MyApp.MyServer.Config.xml")!;
|
||||
|
||||
builder.Services
|
||||
.AddOpcUa()
|
||||
.AddServer(stream) // read + disposed when the host starts the server
|
||||
.AddNodeManager<MyNodeManagerFactory>();
|
||||
```
|
||||
|
||||
The certificate manager and certificate password provider registered in
|
||||
dependency injection are honored the same way as on the options path,
|
||||
and the unmatched user-token-policy startup warning is derived from the
|
||||
policies advertised by the file.
|
||||
|
||||
### Server security and resource controls
|
||||
|
||||
The hosted server is secure by default: sign-and-encrypt policies are included,
|
||||
@@ -504,6 +576,9 @@ properties (bindable from `IConfiguration` or set via the
|
||||
| `RegistrationEndpointUrl` | `SetRegistrationEndpoint(EndpointDescription)` | LDS/GDS endpoint URL the server registers itself with on startup. |
|
||||
| `ReverseConnect` | `SetReverseConnect(ReverseConnectServerConfiguration)` | Server-side reverse-connect clients (see below). |
|
||||
| `OperationLimits` | `SetOperationLimits(OperationLimits)` | Per-service node limits (max nodes per read/write/browse/...). |
|
||||
| `ConfigurationFile` | `LoadApplicationConfigurationAsync(path)` | Load the configuration from an existing OPC UA XML configuration file instead of building it from the options (see [Migrating with an existing configuration XML file](#migrating-with-an-existing-configuration-xml-file)). |
|
||||
| `ConfigurationStream` | `LoadApplicationConfigurationAsync(stream)` | Code-only: load the configuration from a stream (e.g. an embedded resource); read once and disposed at startup. Mutually exclusive with `ConfigurationFile`. |
|
||||
| `ConfigureLoadedConfiguration` | Code-only callback | Override individual settings of the configuration loaded from `ConfigurationFile` / `ConfigurationStream`. |
|
||||
| `ConfigureBuilder` | Code-only callback | Pre-security server-policy and server-option escape hatch, including max failed authentication attempts, sessions, channels, auditing, and HTTPS mutual TLS. |
|
||||
| `ConfigureRateLimits` | Code-only callback | Tunes the default connection and session-establishment admission controls. |
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Because **Data Contract serialization** is not AOT compliant and does not suppor
|
||||
All configuration DTO classes (`ApplicationConfiguration`, `ServerConfiguration`, `TraceConfiguration`, `TransportConfiguration`, `ServerSecurityPolicy`, `OAuth2ServerSettings`, `OAuth2Credential`, `GlobalDiscoveryServerConfiguration`, `CertificateGroupConfiguration`, `BrowserOptions`, etc.) migrated from `[DataContract]`/`[DataMember]` to source-generated `[DataType]`/`[DataTypeField]` attributes and are now `partial` classes.
|
||||
|
||||
- `ApplicationConfiguration.LoadWithNoValidation` uses `XmlParser`/`IEncodeable.Decode()`. Existing XML config files should remain loadable.
|
||||
Server applications moving to the hosted dependency-injection surface can pass such a file directly to `services.AddOpcUa().AddServer("MyServer.Config.xml")` and keep every setting from it — see [Migrating with an existing configuration XML file](../../DependencyInjection.md#migrating-with-an-existing-configuration-xml-file).
|
||||
- Browser and session state persistence switched from XML to OPC UA Binary encoding. **Old persisted files cannot be loaded** — delete and re-save.
|
||||
- `SecuredApplication` uses `SecuredApplicationEncoding` helpers instead of `DataContractSerializer`.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -163,6 +164,189 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
return new OpcUaServerBuilder(builder.Services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
|
||||
/// whose <see cref="ApplicationConfiguration"/> is loaded from an
|
||||
/// existing OPC UA XML configuration file
|
||||
/// (e.g. <c>MyServer.Config.xml</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the migration path for applications that already own a
|
||||
/// configuration file: every setting in the file (base addresses,
|
||||
/// security policies, certificate stores, transport quotas, operation
|
||||
/// limits, ...) is applied as-is, exactly as when the file is loaded
|
||||
/// through <c>ApplicationInstance.LoadApplicationConfigurationAsync</c>.
|
||||
/// Node managers, identity authenticators, roles and the other
|
||||
/// <see cref="IOpcUaServerBuilder"/> registrations compose with the
|
||||
/// loaded file the same way they compose with option-built
|
||||
/// configurations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Equivalent to
|
||||
/// <see cref="AddServer(IOpcUaBuilder, Action{OpcUaServerOptions})"/>
|
||||
/// with <see cref="OpcUaServerOptions.ConfigurationFile"/> and
|
||||
/// <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/> set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The OPC UA builder.</param>
|
||||
/// <param name="configurationFile">Path to the application
|
||||
/// configuration XML file. A relative path is resolved against the
|
||||
/// current working directory.</param>
|
||||
/// <param name="configure">Optional callback invoked with the loaded
|
||||
/// <see cref="ApplicationConfiguration"/> before certificates are
|
||||
/// checked and the server starts; use it to override individual
|
||||
/// settings from code.</param>
|
||||
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
|
||||
/// or <paramref name="configurationFile"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="configurationFile"/>
|
||||
/// is empty or white space.</exception>
|
||||
/// <exception cref="InvalidOperationException">An OPC UA server
|
||||
/// is already registered.</exception>
|
||||
public static IOpcUaServerBuilder AddServer(
|
||||
this IOpcUaBuilder builder,
|
||||
string configurationFile,
|
||||
Action<ApplicationConfiguration>? configure = null)
|
||||
{
|
||||
ValidateConfigurationFilePath(configurationFile);
|
||||
return builder.AddServer(options =>
|
||||
{
|
||||
options.ConfigurationFile = configurationFile;
|
||||
options.ConfigureLoadedConfiguration = configure;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
|
||||
/// using a custom <see cref="StandardServer"/> subclass whose
|
||||
/// <see cref="ApplicationConfiguration"/> is loaded from an existing
|
||||
/// OPC UA XML configuration file (e.g. <c>MyServer.Config.xml</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <see cref="AddServer(IOpcUaBuilder, string, Action{ApplicationConfiguration})"/>
|
||||
/// for how the configuration file is applied.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TServer">The server type created by the hosted service.</typeparam>
|
||||
/// <param name="builder">The OPC UA builder.</param>
|
||||
/// <param name="configurationFile">Path to the application
|
||||
/// configuration XML file. A relative path is resolved against the
|
||||
/// current working directory.</param>
|
||||
/// <param name="configure">Optional callback invoked with the loaded
|
||||
/// <see cref="ApplicationConfiguration"/> before certificates are
|
||||
/// checked and the server starts.</param>
|
||||
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
|
||||
/// or <paramref name="configurationFile"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="configurationFile"/>
|
||||
/// is empty or white space.</exception>
|
||||
/// <exception cref="InvalidOperationException">An OPC UA server
|
||||
/// is already registered.</exception>
|
||||
public static IOpcUaServerBuilder AddServer<
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TServer>(
|
||||
this IOpcUaBuilder builder,
|
||||
string configurationFile,
|
||||
Action<ApplicationConfiguration>? configure = null)
|
||||
where TServer : StandardServer
|
||||
{
|
||||
ValidateConfigurationFilePath(configurationFile);
|
||||
return builder.AddServer<TServer>(options =>
|
||||
{
|
||||
options.ConfigurationFile = configurationFile;
|
||||
options.ConfigureLoadedConfiguration = configure;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
|
||||
/// whose <see cref="ApplicationConfiguration"/> is loaded from a
|
||||
/// stream containing an OPC UA XML configuration document, e.g. an
|
||||
/// embedded resource.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// See <see cref="AddServer(IOpcUaBuilder, string, Action{ApplicationConfiguration})"/>
|
||||
/// for how the loaded configuration is applied. Equivalent to
|
||||
/// <see cref="AddServer(IOpcUaBuilder, Action{OpcUaServerOptions})"/>
|
||||
/// with <see cref="OpcUaServerOptions.ConfigurationStream"/> and
|
||||
/// <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/> set.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The stream must remain open until the host starts the server; it
|
||||
/// is read once and disposed by the hosted service during startup.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="builder">The OPC UA builder.</param>
|
||||
/// <param name="configurationStream">Stream containing the
|
||||
/// application configuration XML document.</param>
|
||||
/// <param name="configure">Optional callback invoked with the loaded
|
||||
/// <see cref="ApplicationConfiguration"/> before certificates are
|
||||
/// checked and the server starts; use it to override individual
|
||||
/// settings from code.</param>
|
||||
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
|
||||
/// or <paramref name="configurationStream"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="InvalidOperationException">An OPC UA server
|
||||
/// is already registered.</exception>
|
||||
public static IOpcUaServerBuilder AddServer(
|
||||
this IOpcUaBuilder builder,
|
||||
Stream configurationStream,
|
||||
Action<ApplicationConfiguration>? configure = null)
|
||||
{
|
||||
if (configurationStream is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configurationStream));
|
||||
}
|
||||
|
||||
return builder.AddServer(options =>
|
||||
{
|
||||
options.ConfigurationStream = configurationStream;
|
||||
options.ConfigureLoadedConfiguration = configure;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
|
||||
/// using a custom <see cref="StandardServer"/> subclass whose
|
||||
/// <see cref="ApplicationConfiguration"/> is loaded from a stream
|
||||
/// containing an OPC UA XML configuration document, e.g. an embedded
|
||||
/// resource.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <see cref="AddServer(IOpcUaBuilder, Stream, Action{ApplicationConfiguration})"/>
|
||||
/// for how the stream is applied and its lifetime.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TServer">The server type created by the hosted service.</typeparam>
|
||||
/// <param name="builder">The OPC UA builder.</param>
|
||||
/// <param name="configurationStream">Stream containing the
|
||||
/// application configuration XML document.</param>
|
||||
/// <param name="configure">Optional callback invoked with the loaded
|
||||
/// <see cref="ApplicationConfiguration"/> before certificates are
|
||||
/// checked and the server starts.</param>
|
||||
/// <returns>An <see cref="IOpcUaServerBuilder"/> for chaining.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="builder"/>
|
||||
/// or <paramref name="configurationStream"/> is <c>null</c>.</exception>
|
||||
/// <exception cref="InvalidOperationException">An OPC UA server
|
||||
/// is already registered.</exception>
|
||||
public static IOpcUaServerBuilder AddServer<
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TServer>(
|
||||
this IOpcUaBuilder builder,
|
||||
Stream configurationStream,
|
||||
Action<ApplicationConfiguration>? configure = null)
|
||||
where TServer : StandardServer
|
||||
{
|
||||
if (configurationStream is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configurationStream));
|
||||
}
|
||||
|
||||
return builder.AddServer<TServer>(options =>
|
||||
{
|
||||
options.ConfigurationStream = configurationStream;
|
||||
options.ConfigureLoadedConfiguration = configure;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an OPC UA server hosted as an <see cref="IHostedService"/>
|
||||
/// with options bound from the supplied <paramref name="configuration"/>
|
||||
@@ -1238,6 +1422,20 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateConfigurationFilePath(string configurationFile)
|
||||
{
|
||||
if (configurationFile is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(configurationFile));
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(configurationFile))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The configuration file path must not be empty.",
|
||||
nameof(configurationFile));
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureFirstRegistration(IServiceCollection services)
|
||||
{
|
||||
foreach (ServiceDescriptor d in services)
|
||||
@@ -1461,6 +1659,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
BindString(section, nameof(OpcUaServerOptions.ProductUri), value => options.ProductUri = value);
|
||||
BindString(section, nameof(OpcUaServerOptions.SubjectName), value => options.SubjectName = value);
|
||||
BindString(section, nameof(OpcUaServerOptions.PkiRoot), value => options.PkiRoot = value);
|
||||
BindString(section, nameof(OpcUaServerOptions.ConfigurationFile),
|
||||
value => options.ConfigurationFile = value);
|
||||
BindString(section, nameof(OpcUaServerOptions.RegistrationEndpointUrl),
|
||||
value => options.RegistrationEndpointUrl = value);
|
||||
BindBoolean(section, nameof(OpcUaServerOptions.AutoAcceptUntrustedCertificates),
|
||||
|
||||
@@ -124,11 +124,34 @@ namespace Opc.Ua.Server.Hosting
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
string[] urls = new string[m_options.EndpointUrls.Count];
|
||||
m_options.EndpointUrls.CopyTo(urls, 0);
|
||||
ICertificateManager? certificateManager =
|
||||
m_services.GetService<ICertificateManager>();
|
||||
if (m_configurationProvider != null)
|
||||
if (HasSuppliedConfiguration)
|
||||
{
|
||||
bool hasConfigurationFile = !string.IsNullOrEmpty(m_options.ConfigurationFile);
|
||||
if (hasConfigurationFile && m_options.ConfigurationStream != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Set only one of OpcUaServerOptions.ConfigurationFile " +
|
||||
"and OpcUaServerOptions.ConfigurationStream.");
|
||||
}
|
||||
if (hasConfigurationFile &&
|
||||
string.IsNullOrWhiteSpace(m_options.ConfigurationFile))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaServerOptions.ConfigurationFile must not be a " +
|
||||
"white-space path.");
|
||||
}
|
||||
|
||||
// An explicitly supplied configuration document is the most
|
||||
// specific intent and therefore wins over a shared
|
||||
// application registered via ConfigureApplication(...).
|
||||
m_ownsApplication = true;
|
||||
await LoadSuppliedApplicationConfigurationAsync(
|
||||
certificateManager,
|
||||
stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (m_configurationProvider != null)
|
||||
{
|
||||
m_application = m_configurationProvider.Application;
|
||||
ApplyDependencyInjectedCertificateManager(certificateManager);
|
||||
@@ -254,7 +277,7 @@ namespace Opc.Ua.Server.Hosting
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string url in urls)
|
||||
foreach (string url in GetListenEndpointUrls(configuration))
|
||||
{
|
||||
m_logger.OPCUAServerListeningAtEndpoint(url);
|
||||
}
|
||||
@@ -310,6 +333,86 @@ namespace Opc.Ua.Server.Hosting
|
||||
await securityOptions.CreateAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an existing configuration XML document was supplied via
|
||||
/// <see cref="OpcUaServerOptions.ConfigurationFile"/> or
|
||||
/// <see cref="OpcUaServerOptions.ConfigurationStream"/>.
|
||||
/// </summary>
|
||||
private bool HasSuppliedConfiguration =>
|
||||
!string.IsNullOrEmpty(m_options.ConfigurationFile) ||
|
||||
m_options.ConfigurationStream != null;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the application configuration from the XML configuration
|
||||
/// document supplied via <see cref="OpcUaServerOptions.ConfigurationFile"/>
|
||||
/// or <see cref="OpcUaServerOptions.ConfigurationStream"/> so every
|
||||
/// setting from the document is applied as-is, then applies the
|
||||
/// optional <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/>
|
||||
/// override callback. A supplied stream is read once and disposed.
|
||||
/// </summary>
|
||||
private async Task LoadSuppliedApplicationConfigurationAsync(
|
||||
ICertificateManager? certificateManager,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IApplicationInstance application = m_applicationFactory.Create(m_telemetry);
|
||||
m_application = application;
|
||||
application.ApplicationType = ApplicationType.Server;
|
||||
application.CertificatePasswordProvider =
|
||||
m_services.GetService<ICertificatePasswordProvider>();
|
||||
|
||||
ApplicationConfiguration configuration;
|
||||
if (m_options.ConfigurationStream is { } configurationStream)
|
||||
{
|
||||
m_logger.LoadingOPCUAServerConfigurationFromStream();
|
||||
using (configurationStream)
|
||||
{
|
||||
configuration = await application
|
||||
.LoadApplicationConfigurationAsync(configurationStream, silent: false, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string configurationFile = m_options.ConfigurationFile!;
|
||||
m_logger.LoadingOPCUAServerConfigurationFromFile(configurationFile);
|
||||
configuration = await application
|
||||
.LoadApplicationConfigurationAsync(configurationFile, silent: false, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
application.ApplicationName = configuration.ApplicationName;
|
||||
m_options.ConfigureLoadedConfiguration?.Invoke(configuration);
|
||||
ApplyDependencyInjectedCertificateManager(certificateManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The endpoint URLs to report as listening: the configured
|
||||
/// <see cref="OpcUaServerOptions.EndpointUrls"/> when present,
|
||||
/// otherwise the base addresses of the effective configuration
|
||||
/// (e.g. when it was loaded from a configuration file).
|
||||
/// </summary>
|
||||
private string[] GetListenEndpointUrls(ApplicationConfiguration configuration)
|
||||
{
|
||||
if (m_options.EndpointUrls.Count > 0)
|
||||
{
|
||||
string[] urls = new string[m_options.EndpointUrls.Count];
|
||||
m_options.EndpointUrls.CopyTo(urls, 0);
|
||||
return urls;
|
||||
}
|
||||
|
||||
if (configuration.ServerConfiguration is { } serverConfiguration)
|
||||
{
|
||||
string[] urls = new string[serverConfiguration.BaseAddresses.Count];
|
||||
for (int i = 0; i < urls.Length; i++)
|
||||
{
|
||||
urls[i] = serverConfiguration.BaseAddresses[i];
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private async Task BindKeyCredentialPushAsync(CancellationToken ct)
|
||||
{
|
||||
if (m_server == null)
|
||||
@@ -348,7 +451,9 @@ namespace Opc.Ua.Server.Hosting
|
||||
authenticators.Add(new AnonymousAuthenticator());
|
||||
}
|
||||
|
||||
WarnForUnmatchedUserTokenPolicies(authenticators);
|
||||
WarnForUnmatchedUserTokenPolicies(
|
||||
authenticators,
|
||||
m_application?.ApplicationConfiguration);
|
||||
|
||||
foreach (IUserTokenAuthenticator authenticator in authenticators)
|
||||
{
|
||||
@@ -433,26 +538,58 @@ namespace Opc.Ua.Server.Hosting
|
||||
return certificates.Count > 0;
|
||||
}
|
||||
|
||||
private void WarnForUnmatchedUserTokenPolicies(IReadOnlyList<IUserTokenAuthenticator> authenticators)
|
||||
private void WarnForUnmatchedUserTokenPolicies(
|
||||
IReadOnlyList<IUserTokenAuthenticator> authenticators,
|
||||
ApplicationConfiguration? configuration)
|
||||
{
|
||||
IEnumerable<OpcUaUserTokenPolicy> policies = m_options.UserTokenPolicies.Count == 0
|
||||
? [new OpcUaUserTokenPolicy { TokenType = UserTokenType.Anonymous }]
|
||||
: m_options.UserTokenPolicies;
|
||||
|
||||
foreach (OpcUaUserTokenPolicy policy in policies)
|
||||
foreach (UserTokenType tokenType in GetAdvertisedUserTokenTypes(configuration))
|
||||
{
|
||||
if (policy.TokenType == UserTokenType.Anonymous)
|
||||
if (tokenType == UserTokenType.Anonymous)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!HasMatchingAuthenticator(policy.TokenType, authenticators))
|
||||
if (!HasMatchingAuthenticator(tokenType, authenticators))
|
||||
{
|
||||
m_logger.UserTokenPolicyTokenTypeIsConfiguredWithout(policy.TokenType);
|
||||
m_logger.UserTokenPolicyTokenTypeIsConfiguredWithout(tokenType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The user token types the server advertises: the option-configured
|
||||
/// policies when present, the policies of the loaded configuration
|
||||
/// document on the <see cref="OpcUaServerOptions.ConfigurationFile"/> /
|
||||
/// <see cref="OpcUaServerOptions.ConfigurationStream"/> path, and the
|
||||
/// anonymous fallback otherwise.
|
||||
/// </summary>
|
||||
private IEnumerable<UserTokenType> GetAdvertisedUserTokenTypes(
|
||||
ApplicationConfiguration? configuration)
|
||||
{
|
||||
if (m_options.UserTokenPolicies.Count > 0)
|
||||
{
|
||||
foreach (OpcUaUserTokenPolicy policy in m_options.UserTokenPolicies)
|
||||
{
|
||||
yield return policy.TokenType;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (HasSuppliedConfiguration &&
|
||||
configuration?.ServerConfiguration != null)
|
||||
{
|
||||
ArrayOf<UserTokenPolicy> policies =
|
||||
configuration.ServerConfiguration.UserTokenPolicies;
|
||||
for (int i = 0; i < policies.Count; i++)
|
||||
{
|
||||
yield return policies[i].TokenType;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return UserTokenType.Anonymous;
|
||||
}
|
||||
|
||||
private static bool HasMatchingAuthenticator(
|
||||
UserTokenType tokenType,
|
||||
IReadOnlyList<IUserTokenAuthenticator> authenticators)
|
||||
@@ -546,5 +683,15 @@ namespace Opc.Ua.Server.Hosting
|
||||
[LoggerMessage(EventId = ServerEventIds.OpcUaServerHostedService + 4, Level = LogLevel.Warning,
|
||||
Message = "Error while stopping OPC UA server.")]
|
||||
public static partial void ErrorWhileStoppingOPCUAServer(this ILogger logger, Exception ex);
|
||||
|
||||
[LoggerMessage(EventId = ServerEventIds.OpcUaServerHostedService + 5, Level = LogLevel.Information,
|
||||
Message = "Loading OPC UA server configuration from file {ConfigurationFile}.")]
|
||||
public static partial void LoadingOPCUAServerConfigurationFromFile(
|
||||
this ILogger logger,
|
||||
string configurationFile);
|
||||
|
||||
[LoggerMessage(EventId = ServerEventIds.OpcUaServerHostedService + 6, Level = LogLevel.Information,
|
||||
Message = "Loading OPC UA server configuration from a stream.")]
|
||||
public static partial void LoadingOPCUAServerConfigurationFromStream(this ILogger logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Opc.Ua.Configuration;
|
||||
|
||||
namespace Opc.Ua.Server.Hosting
|
||||
@@ -43,7 +44,9 @@ namespace Opc.Ua.Server.Hosting
|
||||
/// server-policy and server-option builder stages. Use
|
||||
/// <see cref="OpcUaApplicationOptions.ConfigureSecurity"/> through
|
||||
/// <c>ConfigureApplication(...)</c> for post-security certificate and
|
||||
/// validation settings.
|
||||
/// validation settings. Set <see cref="ConfigurationFile"/> to load the
|
||||
/// configuration from an existing OPC UA XML configuration file instead
|
||||
/// of building it from these options.
|
||||
/// </remarks>
|
||||
public sealed class OpcUaServerOptions
|
||||
{
|
||||
@@ -193,6 +196,64 @@ namespace Opc.Ua.Server.Hosting
|
||||
/// </summary>
|
||||
public OperationLimitsOptions? OperationLimits { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to a classic OPC UA application configuration XML
|
||||
/// file (e.g. <c>MyServer.Config.xml</c>). When set, the hosted
|
||||
/// service loads the <see cref="ApplicationConfiguration"/> from this
|
||||
/// file instead of building one from the other options, so existing
|
||||
/// applications can adopt dependency injection and keep every setting
|
||||
/// from their configuration file (base addresses, security policies,
|
||||
/// certificate stores, transport quotas, operation limits, ...).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relative path is resolved against the current working directory.
|
||||
/// The file must validate as a <see cref="ApplicationType.Server"/>
|
||||
/// (or client-and-server) configuration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because the file is authoritative, the options that feed the
|
||||
/// configuration builder (<see cref="ApplicationName"/>,
|
||||
/// <see cref="EndpointUrls"/>, <see cref="PkiRoot"/>, the policy
|
||||
/// toggles, transport quotas, <see cref="ConfigureBuilder"/>, ...)
|
||||
/// are not applied on this path. Use
|
||||
/// <see cref="ConfigureLoadedConfiguration"/> for programmatic
|
||||
/// overrides of the loaded file. Options that act on the hosted
|
||||
/// server rather than the configuration (<see cref="Identity"/>,
|
||||
/// <see cref="ConfigureRateLimits"/>) keep working. This path also
|
||||
/// takes precedence over a shared application registered with
|
||||
/// <c>ConfigureApplication(...)</c>. Mutually exclusive with
|
||||
/// <see cref="ConfigurationStream"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? ConfigurationFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional stream containing a classic OPC UA application
|
||||
/// configuration XML document, e.g. an embedded resource. When set,
|
||||
/// the hosted service loads the <see cref="ApplicationConfiguration"/>
|
||||
/// from this stream instead of building one from the other options,
|
||||
/// with the same semantics as <see cref="ConfigurationFile"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stream must remain open until the host starts the server; it
|
||||
/// is read once and disposed by the hosted service during startup.
|
||||
/// Mutually exclusive with <see cref="ConfigurationFile"/>.
|
||||
/// </remarks>
|
||||
public Stream? ConfigurationStream { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked with the <see cref="ApplicationConfiguration"/>
|
||||
/// loaded from <see cref="ConfigurationFile"/> or
|
||||
/// <see cref="ConfigurationStream"/>, after the document has been
|
||||
/// read and validated but before certificates are checked and the
|
||||
/// server starts. Use it to override individual settings from code
|
||||
/// without editing the document. Ignored when neither
|
||||
/// <see cref="ConfigurationFile"/> nor
|
||||
/// <see cref="ConfigurationStream"/> is set.
|
||||
/// </summary>
|
||||
public Action<ApplicationConfiguration>? ConfigureLoadedConfiguration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional escape hatch invoked after the standard transport-quota,
|
||||
/// server-policy, and server-option steps, but before the application
|
||||
@@ -200,7 +261,8 @@ namespace Opc.Ua.Server.Hosting
|
||||
/// policies or override server options. Use
|
||||
/// <see cref="OpcUaApplicationOptions.ConfigureSecurity"/> through
|
||||
/// <c>ConfigureApplication(...)</c> for post-security certificate and
|
||||
/// validation settings.
|
||||
/// validation settings. Not applied when <see cref="ConfigurationFile"/>
|
||||
/// or <see cref="ConfigurationStream"/> is set.
|
||||
/// </summary>
|
||||
public Action<IApplicationConfigurationBuilderServerSelected>? ConfigureBuilder { get; set; }
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ public sealed class MyServer : StandardServer
|
||||
|
||||
Then start the server via `ApplicationInstance.StartAsync` (in the
|
||||
`OPCFoundation.NetStandard.Opc.Ua.Configuration` package), or host it
|
||||
through the fluent `services.AddOpcUa().AddServer(...)` DI surface. The
|
||||
through the fluent `services.AddOpcUa().AddServer(...)` DI surface.
|
||||
Applications that already own a classic configuration XML file can pass
|
||||
it directly — `AddServer("MyServer.Config.xml")`, or as a `Stream` for
|
||||
embedded resources — and keep every setting from the file while
|
||||
adopting dependency injection. The
|
||||
raw-socket `opc.tcp://` binding is built in; reference the
|
||||
`OPCFoundation.NetStandard.Opc.Ua.Bindings.Https` package to additionally
|
||||
expose an `https://` endpoint.
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.Server.Hosting;
|
||||
|
||||
namespace Opc.Ua.Server.Tests.Hosting
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises the <c>AddServer(configurationFile)</c> and
|
||||
/// <c>AddServer(configurationStream)</c> migration paths: a hosted OPC UA
|
||||
/// server whose <see cref="ApplicationConfiguration"/> is loaded from an
|
||||
/// existing OPC UA XML configuration document instead of being built from
|
||||
/// <see cref="OpcUaServerOptions"/>. Covers the new <c>IOpcUaBuilder</c>
|
||||
/// overloads, the <see cref="OpcUaServerOptions.ConfigurationFile"/> /
|
||||
/// <see cref="OpcUaServerOptions.ConfigurationStream"/> options and the
|
||||
/// configuration-section binding, the
|
||||
/// <see cref="OpcUaServerOptions.ConfigureLoadedConfiguration"/>
|
||||
/// override callback, precedence over <c>ConfigureApplication(...)</c>,
|
||||
/// mutual exclusion of file and stream, startup failure for missing
|
||||
/// files, and the user-token-policy warning derived from the loaded
|
||||
/// document.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("Server")]
|
||||
[Category("Hosting")]
|
||||
[NonParallelizable]
|
||||
public sealed class ConfigurationFileHostingTests
|
||||
{
|
||||
[Test]
|
||||
public async Task AddServerAppliesAllSettingsFromConfigurationFileAsync()
|
||||
{
|
||||
ConfigurationCaptureServer.Reset();
|
||||
const string applicationName = "CfgXmlApplyServer";
|
||||
string testRoot = CreateTestRoot();
|
||||
int port = GetAvailablePort();
|
||||
string configurationFile = WriteConfigurationFile(testRoot, applicationName, port);
|
||||
var loggerProvider = new CapturingLoggerProvider();
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
||||
services.AddOpcUa().AddServer<ConfigurationCaptureServer>(configurationFile);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => ConfigurationCaptureServer.Started,
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True,
|
||||
"The hosted server did not start from the configuration file.");
|
||||
|
||||
ApplicationConfiguration configuration =
|
||||
ConfigurationCaptureServer.StartedConfiguration ??
|
||||
throw new InvalidOperationException("No configuration was captured.");
|
||||
|
||||
// Every setting captured at server start must come from the file.
|
||||
Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile));
|
||||
Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName));
|
||||
Assert.That(
|
||||
configuration.ApplicationUri,
|
||||
Is.EqualTo("urn:opcfoundation:test:" + applicationName));
|
||||
Assert.That(
|
||||
configuration.ProductUri,
|
||||
Is.EqualTo("uri:opcfoundation.org:test:" + applicationName));
|
||||
Assert.That(configuration.TransportQuotas, Is.Not.Null);
|
||||
Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321));
|
||||
Assert.That(configuration.TransportQuotas.OperationTimeout, Is.EqualTo(90000));
|
||||
Assert.That(configuration.ServerConfiguration, Is.Not.Null);
|
||||
Assert.That(configuration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(77));
|
||||
Assert.That(configuration.ServerConfiguration.BaseAddresses.Count, Is.EqualTo(1));
|
||||
string expectedBaseAddress = Utils.ReplaceLocalhost(FormatBaseAddress(applicationName, port))!;
|
||||
Assert.That(
|
||||
configuration.ServerConfiguration.BaseAddresses[0],
|
||||
Is.EqualTo(expectedBaseAddress));
|
||||
Assert.That(
|
||||
configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates,
|
||||
Is.True);
|
||||
Assert.That(
|
||||
configuration.SecurityConfiguration.ApplicationCertificates[0].StorePath,
|
||||
Is.EqualTo(GetPkiRoot(testRoot) + "/own"));
|
||||
|
||||
// The hosted service reports the listen endpoints from the loaded
|
||||
// file because OpcUaServerOptions.EndpointUrls is empty on this path.
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => loggerProvider.Messages.Any(message =>
|
||||
message.Contains("listening", StringComparison.OrdinalIgnoreCase) &&
|
||||
message.Contains(expectedBaseAddress, StringComparison.Ordinal)),
|
||||
TimeSpan.FromSeconds(30)).ConfigureAwait(false),
|
||||
Is.True,
|
||||
"The endpoint from the configuration file was not reported as listening.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddServerStartsDefaultServerFromConfigurationFileAsync()
|
||||
{
|
||||
string testRoot = CreateTestRoot();
|
||||
string configurationFile = WriteConfigurationFile(
|
||||
testRoot,
|
||||
"CfgXmlDefaultServer",
|
||||
GetAvailablePort());
|
||||
var startupTask = new RecordingStartupTask();
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IServerStartupTask>(startupTask);
|
||||
services.AddOpcUa().AddServer(configurationFile);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => startupTask.InvocationCount > 0,
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True,
|
||||
"The default hosted server did not start from the configuration file.");
|
||||
|
||||
OpcUaServerOptions options = fixture.Services
|
||||
.GetRequiredService<IOptions<OpcUaServerOptions>>().Value;
|
||||
Assert.That(options.ConfigurationFile, Is.EqualTo(configurationFile));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ConfigureLoadedConfigurationOverridesIndividualSettingsAsync()
|
||||
{
|
||||
ConfigurationCaptureServer.Reset();
|
||||
string testRoot = CreateTestRoot();
|
||||
string configurationFile = WriteConfigurationFile(
|
||||
testRoot,
|
||||
"CfgXmlOverrideServer",
|
||||
GetAvailablePort());
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddServer<ConfigurationCaptureServer>(
|
||||
configurationFile,
|
||||
configuration =>
|
||||
configuration.ServerConfiguration!.MaxSessionCount = 4242);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => ConfigurationCaptureServer.Started,
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True);
|
||||
|
||||
ApplicationConfiguration configuration =
|
||||
ConfigurationCaptureServer.StartedConfiguration ??
|
||||
throw new InvalidOperationException("No configuration was captured.");
|
||||
|
||||
// The callback override wins over the file value while every
|
||||
// untouched setting keeps its file value.
|
||||
Assert.That(configuration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(4242));
|
||||
Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321));
|
||||
Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ConfigurationFileTakesPrecedenceOverConfigureApplicationAsync()
|
||||
{
|
||||
ConfigurationCaptureServer.Reset();
|
||||
const string applicationName = "CfgXmlPrecedenceServer";
|
||||
string testRoot = CreateTestRoot();
|
||||
string configurationFile = WriteConfigurationFile(
|
||||
testRoot,
|
||||
applicationName,
|
||||
GetAvailablePort());
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging();
|
||||
services.AddOpcUa()
|
||||
.ConfigureApplication(options =>
|
||||
{
|
||||
options.ApplicationName = "SharedHostedApplication";
|
||||
options.ApplicationUri = "urn:localhost:SharedHostedApplication";
|
||||
options.PkiRoot = Path.Combine(testRoot, "sharedpki");
|
||||
options.AutoAcceptUntrustedCertificates = true;
|
||||
})
|
||||
.AddServer<ConfigurationCaptureServer>(configurationFile);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => ConfigurationCaptureServer.Started,
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True);
|
||||
|
||||
ApplicationConfiguration configuration =
|
||||
ConfigurationCaptureServer.StartedConfiguration ??
|
||||
throw new InvalidOperationException("No configuration was captured.");
|
||||
|
||||
Assert.That(configuration.SourceFilePath, Is.EqualTo(configurationFile));
|
||||
Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UnmatchedUserNamePolicyFromConfigurationFileLogsWarningAsync()
|
||||
{
|
||||
string testRoot = CreateTestRoot();
|
||||
string configurationFile = WriteConfigurationFile(
|
||||
testRoot,
|
||||
"CfgXmlWarnServer",
|
||||
GetAvailablePort(),
|
||||
AnonymousTokenPolicyXml + UserNameTokenPolicyXml);
|
||||
var loggerProvider = new CapturingLoggerProvider();
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging(builder => builder.AddProvider(loggerProvider));
|
||||
// No user database / user management is registered, so the
|
||||
// UserName policy advertised by the file has no matching
|
||||
// authenticator and the hosted service must warn.
|
||||
services.AddOpcUa().AddServer(configurationFile);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => loggerProvider.Messages.Any(message =>
|
||||
message.Contains("UserName", StringComparison.Ordinal) &&
|
||||
message.Contains(
|
||||
"without a matching identity authenticator",
|
||||
StringComparison.Ordinal)),
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True,
|
||||
"Expected a warning for the unmatched UserName token policy from the file.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MissingConfigurationFileFailsStartupAsync()
|
||||
{
|
||||
string missingFile = Path.Combine(CreateTestRoot(), "DoesNotExist.Config.xml");
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddServer(missingFile);
|
||||
|
||||
Exception? failure = await StartAndCaptureStartupFailureAsync(services)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(failure, Is.InstanceOf<ServiceResultException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddServerAppliesSettingsFromConfigurationStreamAsync()
|
||||
{
|
||||
ConfigurationCaptureServer.Reset();
|
||||
const string applicationName = "CfgXmlStreamServer";
|
||||
string testRoot = CreateTestRoot();
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(BuildConfigurationXml(
|
||||
applicationName,
|
||||
GetAvailablePort(),
|
||||
GetPkiRoot(testRoot))));
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddServer<ConfigurationCaptureServer>(
|
||||
stream,
|
||||
configuration =>
|
||||
configuration.ServerConfiguration!.MaxSessionCount = 555);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => ConfigurationCaptureServer.Started,
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True,
|
||||
"The hosted server did not start from the configuration stream.");
|
||||
|
||||
ApplicationConfiguration configuration =
|
||||
ConfigurationCaptureServer.StartedConfiguration ??
|
||||
throw new InvalidOperationException("No configuration was captured.");
|
||||
|
||||
// Settings come from the stream document; a stream has no source
|
||||
// file path; the override callback wins over the document value.
|
||||
Assert.That(configuration.SourceFilePath, Is.Null);
|
||||
Assert.That(configuration.ApplicationName, Is.EqualTo(applicationName));
|
||||
Assert.That(configuration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321));
|
||||
Assert.That(configuration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(555));
|
||||
Assert.That(configuration.ServerConfiguration.BaseAddresses.Count, Is.EqualTo(1));
|
||||
|
||||
// The hosted service disposes the stream after reading it.
|
||||
Assert.That(stream.CanRead, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddServerStartsDefaultServerFromConfigurationStreamAsync()
|
||||
{
|
||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(BuildConfigurationXml(
|
||||
"CfgXmlStreamDefault",
|
||||
GetAvailablePort(),
|
||||
GetPkiRoot(CreateTestRoot()))));
|
||||
var startupTask = new RecordingStartupTask();
|
||||
|
||||
await using HostedServerFixture fixture = await HostedServerFixture.StartAsync(
|
||||
services =>
|
||||
{
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IServerStartupTask>(startupTask);
|
||||
services.AddOpcUa().AddServer(stream);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(
|
||||
await WaitForAsync(
|
||||
() => startupTask.InvocationCount > 0,
|
||||
TimeSpan.FromSeconds(60)).ConfigureAwait(false),
|
||||
Is.True,
|
||||
"The default hosted server did not start from the configuration stream.");
|
||||
|
||||
OpcUaServerOptions options = fixture.Services
|
||||
.GetRequiredService<IOptions<OpcUaServerOptions>>().Value;
|
||||
Assert.That(options.ConfigurationStream, Is.SameAs(stream));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhiteSpaceConfigurationFileFailsStartupWithClearErrorAsync()
|
||||
{
|
||||
// A white-space path can reach the options through configuration
|
||||
// binding or the options callback, bypassing the AddServer
|
||||
// argument validation. Startup must fail with a clear error
|
||||
// instead of a confusing file-load failure.
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddServer(options => options.ConfigurationFile = " ");
|
||||
|
||||
Exception? failure = await StartAndCaptureStartupFailureAsync(services)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(failure, Is.InstanceOf<InvalidOperationException>());
|
||||
Assert.That(failure!.Message, Does.Contain("white-space"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ConfigurationFileAndStreamTogetherFailStartupAsync()
|
||||
{
|
||||
using var stream = new MemoryStream([1, 2, 3]);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa().AddServer(options =>
|
||||
{
|
||||
options.ConfigurationFile = "Some.Config.xml";
|
||||
options.ConfigurationStream = stream;
|
||||
});
|
||||
|
||||
Exception? failure = await StartAndCaptureStartupFailureAsync(services)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(failure, Is.InstanceOf<InvalidOperationException>());
|
||||
Assert.That(failure!.Message, Does.Contain("only one"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddServerConfigurationFileValidatesArguments()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
IOpcUaBuilder builder = services.AddOpcUa();
|
||||
|
||||
Assert.That(
|
||||
() => default(IOpcUaBuilder)!.AddServer("Server.Config.xml"),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
||||
Assert.That(
|
||||
() => builder.AddServer((string)null!),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationFile"));
|
||||
Assert.That(
|
||||
() => builder.AddServer(" "),
|
||||
Throws.TypeOf<ArgumentException>().With
|
||||
.Property(nameof(ArgumentException.ParamName)).EqualTo("configurationFile"));
|
||||
|
||||
Assert.That(
|
||||
() => default(IOpcUaBuilder)!.AddServer<ConfigurationCaptureServer>(
|
||||
"Server.Config.xml"),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
||||
Assert.That(
|
||||
() => builder.AddServer<ConfigurationCaptureServer>((string)null!),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationFile"));
|
||||
Assert.That(
|
||||
() => builder.AddServer<ConfigurationCaptureServer>(" "),
|
||||
Throws.TypeOf<ArgumentException>().With
|
||||
.Property(nameof(ArgumentException.ParamName)).EqualTo("configurationFile"));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
Assert.That(
|
||||
() => default(IOpcUaBuilder)!.AddServer(stream),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
||||
Assert.That(
|
||||
() => builder.AddServer((Stream)null!),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationStream"));
|
||||
Assert.That(
|
||||
() => default(IOpcUaBuilder)!.AddServer<ConfigurationCaptureServer>(stream),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
||||
Assert.That(
|
||||
() => builder.AddServer<ConfigurationCaptureServer>((Stream)null!),
|
||||
Throws.ArgumentNullException.With
|
||||
.Property(nameof(ArgumentNullException.ParamName)).EqualTo("configurationStream"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the provider, starts the single hosted service, and returns
|
||||
/// the exception the startup failed with (from a synchronous
|
||||
/// <c>StartAsync</c> fault or the faulted execute task), or
|
||||
/// <c>null</c> when startup did not fail promptly. Disposes the
|
||||
/// provider before returning.
|
||||
/// </summary>
|
||||
private static async Task<Exception?> StartAndCaptureStartupFailureAsync(
|
||||
ServiceCollection services)
|
||||
{
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
try
|
||||
{
|
||||
IHostedService hostedService = provider.GetServices<IHostedService>().Single();
|
||||
try
|
||||
{
|
||||
await hostedService.StartAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
Task? executeTask = (hostedService as BackgroundService)?.ExecuteTask;
|
||||
if (executeTask == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Task completed = await Task.WhenAny(
|
||||
executeTask,
|
||||
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
|
||||
if (!ReferenceEquals(completed, executeTask))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return executeTask.Exception?.GetBaseException();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await provider.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddServerConfigurationFileThrowsWhenServerAlreadyRegistered()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
IOpcUaBuilder builder = services.AddOpcUa();
|
||||
builder.AddServer("First.Config.xml");
|
||||
|
||||
Assert.That(
|
||||
() => builder.AddServer("Second.Config.xml"),
|
||||
Throws.InvalidOperationException);
|
||||
Assert.That(
|
||||
() => builder.AddServer<ConfigurationCaptureServer>("Third.Config.xml"),
|
||||
Throws.InvalidOperationException);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AddServerBindsConfigurationFileFromConfigurationSection()
|
||||
{
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:Server:ApplicationName"] = "BoundConfigFileServer",
|
||||
["OpcUa:Server:ConfigurationFile"] = "Legacy/Server.Config.xml"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
using ServiceProvider provider = services.AddOpcUa()
|
||||
.AddServer(configuration)
|
||||
.Services.BuildServiceProvider();
|
||||
|
||||
OpcUaServerOptions options = provider
|
||||
.GetRequiredService<IOptions<OpcUaServerOptions>>().Value;
|
||||
|
||||
Assert.That(options.ConfigurationFile, Is.EqualTo("Legacy/Server.Config.xml"));
|
||||
Assert.That(options.ApplicationName, Is.EqualTo("BoundConfigFileServer"));
|
||||
}
|
||||
|
||||
private const string AnonymousTokenPolicyXml = """
|
||||
<ua:UserTokenPolicy>
|
||||
<ua:TokenType>Anonymous_0</ua:TokenType>
|
||||
</ua:UserTokenPolicy>
|
||||
""";
|
||||
|
||||
private const string UserNameTokenPolicyXml = """
|
||||
<ua:UserTokenPolicy>
|
||||
<ua:TokenType>UserName_1</ua:TokenType>
|
||||
</ua:UserTokenPolicy>
|
||||
""";
|
||||
|
||||
private static string CreateTestRoot()
|
||||
{
|
||||
// Keep the root short: certificate file names would otherwise push
|
||||
// the PFX path past MAX_PATH on .NET Framework.
|
||||
string testRoot = Path.Combine(
|
||||
TestContext.CurrentContext.WorkDirectory,
|
||||
"cfgxml",
|
||||
Guid.NewGuid().ToString("N")[..8]);
|
||||
Directory.CreateDirectory(testRoot);
|
||||
return testRoot;
|
||||
}
|
||||
|
||||
private static string GetPkiRoot(string testRoot)
|
||||
{
|
||||
return Path.Combine(testRoot, "pki").Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string FormatBaseAddress(string applicationName, int port)
|
||||
{
|
||||
return "opc.tcp://localhost:" +
|
||||
port.ToString(CultureInfo.InvariantCulture) +
|
||||
"/" +
|
||||
applicationName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a classic OPC UA server configuration XML file of the shape
|
||||
/// existing applications carry (security configuration, transport
|
||||
/// quotas, server configuration) with distinctive values the tests
|
||||
/// can assert on.
|
||||
/// </summary>
|
||||
private static string WriteConfigurationFile(
|
||||
string testRoot,
|
||||
string applicationName,
|
||||
int port,
|
||||
string? userTokenPoliciesXml = null)
|
||||
{
|
||||
string configurationFile = Path.Combine(testRoot, applicationName + ".Config.xml");
|
||||
File.WriteAllText(
|
||||
configurationFile,
|
||||
BuildConfigurationXml(applicationName, port, GetPkiRoot(testRoot), userTokenPoliciesXml));
|
||||
return configurationFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the classic OPC UA server configuration XML document used
|
||||
/// by both the file-based and the stream-based tests.
|
||||
/// </summary>
|
||||
private static string BuildConfigurationXml(
|
||||
string applicationName,
|
||||
int port,
|
||||
string pkiRoot,
|
||||
string? userTokenPoliciesXml = null)
|
||||
{
|
||||
return $"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ApplicationConfiguration
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
|
||||
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd">
|
||||
<ApplicationName>{applicationName}</ApplicationName>
|
||||
<ApplicationUri>urn:opcfoundation:test:{applicationName}</ApplicationUri>
|
||||
<ProductUri>uri:opcfoundation.org:test:{applicationName}</ProductUri>
|
||||
<ApplicationType>Server_0</ApplicationType>
|
||||
<SecurityConfiguration>
|
||||
<ApplicationCertificates>
|
||||
<CertificateIdentifier>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/own</StorePath>
|
||||
<SubjectName>CN={applicationName}, O=OPC Foundation, DC=localhost</SubjectName>
|
||||
<CertificateTypeString>RsaSha256</CertificateTypeString>
|
||||
</CertificateIdentifier>
|
||||
</ApplicationCertificates>
|
||||
<TrustedIssuerCertificates>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/issuer</StorePath>
|
||||
</TrustedIssuerCertificates>
|
||||
<TrustedPeerCertificates>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/trusted</StorePath>
|
||||
</TrustedPeerCertificates>
|
||||
<RejectedCertificateStore>
|
||||
<StoreType>Directory</StoreType>
|
||||
<StorePath>{pkiRoot}/rejected</StorePath>
|
||||
</RejectedCertificateStore>
|
||||
<AutoAcceptUntrustedCertificates>true</AutoAcceptUntrustedCertificates>
|
||||
</SecurityConfiguration>
|
||||
<TransportConfigurations></TransportConfigurations>
|
||||
<TransportQuotas>
|
||||
<OperationTimeout>90000</OperationTimeout>
|
||||
<MaxStringLength>654321</MaxStringLength>
|
||||
<MaxByteStringLength>1048576</MaxByteStringLength>
|
||||
<MaxArrayLength>65535</MaxArrayLength>
|
||||
<MaxMessageSize>4194304</MaxMessageSize>
|
||||
<MaxBufferSize>65535</MaxBufferSize>
|
||||
<ChannelLifetime>300000</ChannelLifetime>
|
||||
<SecurityTokenLifetime>3600000</SecurityTokenLifetime>
|
||||
</TransportQuotas>
|
||||
<ServerConfiguration>
|
||||
<BaseAddresses>
|
||||
<ua:String>{FormatBaseAddress(applicationName, port)}</ua:String>
|
||||
</BaseAddresses>
|
||||
<SecurityPolicies>
|
||||
<ServerSecurityPolicy>
|
||||
<SecurityMode>None_1</SecurityMode>
|
||||
<SecurityPolicyUri>http://opcfoundation.org/UA/SecurityPolicy#None</SecurityPolicyUri>
|
||||
</ServerSecurityPolicy>
|
||||
</SecurityPolicies>
|
||||
<UserTokenPolicies>
|
||||
{userTokenPoliciesXml ?? AnonymousTokenPolicyXml}
|
||||
</UserTokenPolicies>
|
||||
<DiagnosticsEnabled>false</DiagnosticsEnabled>
|
||||
<MaxSessionCount>77</MaxSessionCount>
|
||||
<MinSessionTimeout>10000</MinSessionTimeout>
|
||||
<MaxSessionTimeout>3600000</MaxSessionTimeout>
|
||||
</ServerConfiguration>
|
||||
</ApplicationConfiguration>
|
||||
""";
|
||||
}
|
||||
|
||||
private static int GetAvailablePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
DateTime deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
await Task.Delay(100).ConfigureAwait(false);
|
||||
}
|
||||
return condition();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="StandardServer"/> that captures the effective
|
||||
/// <see cref="ApplicationConfiguration"/> the hosted service starts
|
||||
/// it with, so tests can assert that the settings of the loaded
|
||||
/// configuration file were applied.
|
||||
/// </summary>
|
||||
public sealed class ConfigurationCaptureServer : StandardServer
|
||||
{
|
||||
public ConfigurationCaptureServer(ITelemetryContext telemetry, TimeProvider timeProvider)
|
||||
: base(telemetry, timeProvider)
|
||||
{
|
||||
}
|
||||
|
||||
public static ApplicationConfiguration? StartedConfiguration { get; private set; }
|
||||
|
||||
public static bool Started { get; private set; }
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
StartedConfiguration = null;
|
||||
Started = false;
|
||||
}
|
||||
|
||||
protected override void OnServerStarting(ApplicationConfiguration configuration)
|
||||
{
|
||||
StartedConfiguration = configuration;
|
||||
base.OnServerStarting(configuration);
|
||||
}
|
||||
|
||||
protected override void OnServerStarted(IServerInternal server)
|
||||
{
|
||||
Started = true;
|
||||
base.OnServerStarted(server);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingStartupTask : IServerStartupTask
|
||||
{
|
||||
public int InvocationCount => Volatile.Read(ref m_invocationCount);
|
||||
|
||||
public ValueTask OnServerStartedAsync(
|
||||
IServerContext server,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref m_invocationCount);
|
||||
return default;
|
||||
}
|
||||
|
||||
private int m_invocationCount;
|
||||
}
|
||||
|
||||
private sealed class HostedServerFixture : IAsyncDisposable
|
||||
{
|
||||
private HostedServerFixture(ServiceProvider provider, IHostedService hostedService)
|
||||
{
|
||||
m_provider = provider;
|
||||
m_hostedService = hostedService;
|
||||
}
|
||||
|
||||
public static async ValueTask<HostedServerFixture> StartAsync(
|
||||
Action<IServiceCollection> configureServices)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
configureServices(services);
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
IHostedService hostedService = provider.GetServices<IHostedService>().Single();
|
||||
try
|
||||
{
|
||||
await hostedService.StartAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
return new HostedServerFixture(provider, hostedService);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await provider.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public IServiceProvider Services => m_provider;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await m_hostedService.StopAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await m_provider.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ServiceProvider m_provider;
|
||||
private readonly IHostedService m_hostedService;
|
||||
}
|
||||
|
||||
private sealed class CapturingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public ConcurrentBag<string> Messages { get; } = [];
|
||||
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
return new CapturingLogger(Messages);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingLogger : ILogger
|
||||
{
|
||||
public CapturingLogger(ConcurrentBag<string> messages)
|
||||
{
|
||||
m_messages = messages;
|
||||
}
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
{
|
||||
return NoopDisposable.Instance;
|
||||
}
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
m_messages.Add(formatter(state, exception));
|
||||
}
|
||||
|
||||
private readonly ConcurrentBag<string> m_messages;
|
||||
}
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public static readonly NoopDisposable Instance = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user