docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -18,11 +18,17 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
private readonly IActorRef _proxy;
/// <summary>Initializes a new instance of the AdminOperationsClient.</summary>
/// <param name="registry">The actor registry to resolve the admin operations singleton proxy.</param>
public AdminOperationsClient(ActorRegistry registry)
{
_proxy = registry.Get<AdminOperationsActorKey>();
}
/// <summary>Starts a deployment via the admin operations actor.</summary>
/// <param name="createdBy">The username of who initiated the deployment.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The deployment start result.</returns>
public async Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct)
{
var msg = new StartDeployment(createdBy, CorrelationId.NewId());
@@ -22,12 +22,19 @@ public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
private readonly ActorSystem _system;
private readonly string _systemName;
/// <summary>Initializes a new FleetDiagnosticsClient with the given actor system and cluster options.</summary>
/// <param name="system">The Akka actor system.</param>
/// <param name="options">Cluster configuration options.</param>
public FleetDiagnosticsClient(ActorSystem system, IOptions<AkkaClusterOptions> options)
{
_system = system;
_systemName = options.Value.SystemName;
}
/// <summary>Gets diagnostics for a cluster node.</summary>
/// <param name="nodeId">The node identifier to query.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Diagnostics snapshot for the node, or an empty snapshot if the query fails.</returns>
public async Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct)
{
var selection = _system.ActorSelection($"akka.tcp://{_systemName}@{nodeId.Value}/user/driver-host");
@@ -3,8 +3,13 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
/// <summary>Service collection extensions for Admin UI client registration.</summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the Admin UI client services in the dependency injection container.
/// </summary>
/// <param name="services">The service collection to register clients into.</param>
public static IServiceCollection AddOtOpcUaAdminClients(this IServiceCollection services)
{
services.AddScoped<IAdminOperationsClient, AdminOperationsClient>();
@@ -17,6 +17,8 @@ public static class EndpointRouteBuilderExtensions
/// Blazor pipeline but the only built-in components are the v2-native ones added in this
/// library (e.g. <c>Deployments</c>, Task 52).
/// </summary>
/// <typeparam name="TApp">The root component type for Razor pages.</typeparam>
/// <param name="app">The endpoint route builder.</param>
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
where TApp : IComponent
{
@@ -28,6 +30,10 @@ public static class EndpointRouteBuilderExtensions
return app;
}
/// <summary>
/// Adds AdminUI services to the dependency injection container.
/// </summary>
/// <param name="services">The service collection.</param>
public static IServiceCollection AddAdminUI(this IServiceCollection services)
{
services.AddRazorComponents().AddInteractiveServerComponents();
@@ -19,9 +19,17 @@ public sealed class AlertSignalRBridge : ReceiveActor
private readonly IHubContext<AlertHub> _hub;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>
/// Creates actor props for the AlertSignalRBridge.
/// </summary>
/// <param name="hub">The SignalR hub context to send alerts to.</param>
public static Props Props(IHubContext<AlertHub> hub) =>
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub));
/// <summary>
/// Initializes a new instance of the AlertSignalRBridge actor.
/// </summary>
/// <param name="hub">The SignalR hub context to send alerts to.</param>
public AlertSignalRBridge(IHubContext<AlertHub> hub)
{
_hub = hub;
@@ -29,6 +37,7 @@ public sealed class AlertSignalRBridge : ReceiveActor
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
}
/// <inheritdoc />
protected override void PreStart() =>
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
@@ -25,9 +25,14 @@ public sealed class FleetStatusSignalRBridge : ReceiveActor
private readonly IHubContext<FleetStatusHub> _hub;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates actor props for a FleetStatusSignalRBridge with the specified hub context.</summary>
/// <param name="hub">The SignalR hub context.</param>
/// <returns>Props for creating a new FleetStatusSignalRBridge actor.</returns>
public static Props Props(IHubContext<FleetStatusHub> hub) =>
Akka.Actor.Props.Create(() => new FleetStatusSignalRBridge(hub));
/// <summary>Initializes a new instance of the FleetStatusSignalRBridge class.</summary>
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
public FleetStatusSignalRBridge(IHubContext<FleetStatusHub> hub)
{
_hub = hub;
@@ -35,6 +40,7 @@ public sealed class FleetStatusSignalRBridge : ReceiveActor
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
}
/// <inheritdoc />
protected override void PreStart() =>
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
@@ -3,8 +3,13 @@ using Microsoft.AspNetCore.Routing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// <summary>Extension methods for mapping OtOpcUa SignalR hubs to routes.</summary>
public static class HubRouteBuilderExtensions
{
/// <summary>
/// Maps all OtOpcUa Admin UI SignalR hubs to their configured endpoints.
/// </summary>
/// <param name="app">The endpoint route builder to register hubs on.</param>
public static IEndpointRouteBuilder MapOtOpcUaHubs(this IEndpointRouteBuilder app)
{
app.MapHub<FleetStatusHub>(FleetStatusHub.Endpoint);
@@ -25,6 +25,7 @@ public static class HubServiceCollectionExtensions
/// }
/// </code>
/// </summary>
/// <param name="builder">The Akka configuration builder.</param>
public static AkkaConfigurationBuilder WithOtOpcUaSignalRBridges(this AkkaConfigurationBuilder builder)
{
builder.WithActors((system, registry, resolver) =>
@@ -17,9 +17,13 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
private readonly IHubContext<ScriptLogHub> _hub;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates a Props instance for the ScriptLogSignalRBridge.</summary>
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
public static Props Props(IHubContext<ScriptLogHub> hub) =>
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub));
/// <summary>Initializes a new instance of the <see cref="ScriptLogSignalRBridge"/> class.</summary>
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
public ScriptLogSignalRBridge(IHubContext<ScriptLogHub> hub)
{
_hub = hub;
@@ -27,6 +31,7 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
Receive<SubscribeAck>(_ => { /* DPS confirmation */ });
}
/// <inheritdoc />
protected override void PreStart() =>
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(TopicName, Self));
@@ -21,11 +21,18 @@ public sealed class AdminOperationsActor : ReceiveActor
private readonly IActorRef _coordinator;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates actor props for the admin operations actor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
/// <returns>Props configured to create an AdminOperationsActor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator) =>
Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator));
/// <summary>Initializes a new instance of the AdminOperationsActor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param>
public AdminOperationsActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator)
@@ -22,6 +22,9 @@ public static class ConfigComposer
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
};
/// <summary>Reads the current configuration and returns a deterministic snapshot blob with revision hash.</summary>
/// <param name="db">The configuration database context.</param>
/// <param name="ct">The cancellation token for the operation.</param>
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default)
{
@@ -49,6 +52,7 @@ public static class ConfigComposer
}
/// <summary>Returns the SHA-256 hex digest of the supplied artifact bytes (lowercase, no prefix).</summary>
/// <param name="blob">The bytes to hash.</param>
public static string HashOf(ReadOnlySpan<byte> blob) =>
Convert.ToHexStringLower(SHA256.HashData(blob));
}
@@ -29,11 +29,16 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly Dictionary<Guid, AuditEvent> _buffer = new();
/// <summary>Gets or sets the timer scheduler for the actor.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates a Props factory for the AuditWriterActor.</summary>
/// <param name="dbFactory">The database context factory for creating ConfigDb connections.</param>
public static Props Props(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) =>
Akka.Actor.Props.Create(() => new AuditWriterActor(dbFactory));
/// <summary>Initializes a new instance of the AuditWriterActor class.</summary>
/// <param name="dbFactory">The database context factory for creating ConfigDb connections.</param>
public AuditWriterActor(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
_dbFactory = dbFactory;
@@ -41,6 +46,7 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
Receive<Flush>(_ => FlushBuffer());
}
/// <inheritdoc />
protected override void PreStart()
{
Timers.StartPeriodicTimer("flush", Flush.Instance, FlushInterval);
@@ -86,12 +92,14 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers
}
}
/// <inheritdoc />
protected override void PreRestart(Exception reason, object message)
{
FlushBuffer();
base.PreRestart(reason, message);
}
/// <inheritdoc />
protected override void PostStop()
{
FlushBuffer();
@@ -35,13 +35,22 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
private DeploymentId? _current;
private HashSet<NodeId> _expectedAcks = new();
/// <summary>Gets the timer scheduler for managing apply deadlines.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates actor props for the ConfigPublishCoordinator with the specified configuration.</summary>
/// <param name="dbFactory">The database context factory for accessing configuration data.</param>
/// <param name="applyDeadline">The timeout for waiting for apply acknowledgments from cluster nodes.</param>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
TimeSpan? applyDeadline = null) =>
Akka.Actor.Props.Create(() => new ConfigPublishCoordinator(dbFactory, applyDeadline ?? DefaultApplyDeadline));
/// <summary>
/// Initializes a new instance of the <see cref="ConfigPublishCoordinator"/> class.
/// </summary>
/// <param name="dbFactory">The database context factory for persisting deployment state.</param>
/// <param name="applyDeadline">The timeout for waiting for per-node apply acknowledgments.</param>
public ConfigPublishCoordinator(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
TimeSpan applyDeadline)
@@ -59,6 +68,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
/// died. We re-derive <c>_expectedAcks</c> from <c>NodeDeploymentState</c>, replay the ACKs
/// that already landed in the DB, and resume the deadline timer.
/// </summary>
/// <inheritdoc />
protected override void PreStart()
{
// Subscribe to per-node ApplyAck broadcasts so DriverHostActors on remote members can
@@ -28,6 +28,7 @@ public sealed class FleetStatusBroadcaster : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
private readonly Dictionary<NodeId, NodeRecord> _nodes = new();
/// <summary>Gets or sets the timer scheduler for this actor.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
@@ -36,9 +37,14 @@ public sealed class FleetStatusBroadcaster : ReceiveActor, IWithTimers
/// </summary>
public sealed record DriverHostStatusHeartbeat(NodeId NodeId, RevisionHash? CurrentRevision);
/// <summary>Creates actor props for the fleet status broadcaster.</summary>
/// <param name="broadcast">Optional custom broadcast delegate; defaults to distributed pub-sub mediator.</param>
/// <returns>Props configured to create a FleetStatusBroadcaster.</returns>
public static Props Props(Action<object>? broadcast = null) =>
Akka.Actor.Props.Create(() => new FleetStatusBroadcaster(broadcast));
/// <summary>Initializes a new instance of the FleetStatusBroadcaster actor.</summary>
/// <param name="broadcast">Optional custom broadcast delegate; if null, uses distributed pub-sub mediator.</param>
public FleetStatusBroadcaster(Action<object>? broadcast = null)
{
_cluster = Akka.Cluster.Cluster.Get(Context.System);
@@ -55,6 +61,7 @@ public sealed class FleetStatusBroadcaster : ReceiveActor, IWithTimers
Receive<Tick>(_ => OnTick());
}
/// <inheritdoc />
protected override void PreStart()
{
_cluster.Subscribe(
@@ -67,6 +74,7 @@ public sealed class FleetStatusBroadcaster : ReceiveActor, IWithTimers
Timers.StartPeriodicTimer("tick", Tick.Instance, BroadcastInterval);
}
/// <inheritdoc />
protected override void PostStop() => _cluster.Unsubscribe(Self);
private void OnMemberUp(Member m)
@@ -27,13 +27,20 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
private readonly Action<object>? _broadcastOverride;
private bool _dirty;
/// <summary>Gets the timer scheduler for this actor.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates a Props for creating a RedundancyStateActor.</summary>
/// <param name="broadcast">Optional broadcast override for testing.</param>
/// <returns>Props for actor creation.</returns>
public static Props Props(Action<object>? broadcast = null) =>
Akka.Actor.Props.Create(() => new RedundancyStateActor(broadcast));
/// <summary>Initializes a new instance of RedundancyStateActor with no broadcast override.</summary>
public RedundancyStateActor() : this(broadcast: null) { }
/// <summary>Initializes a new instance of RedundancyStateActor.</summary>
/// <param name="broadcast">Optional broadcast override for testing.</param>
public RedundancyStateActor(Action<object>? broadcast)
{
_cluster = Akka.Cluster.Cluster.Get(Context.System);
@@ -47,6 +54,7 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
Receive<RecomputeNow>(_ => PublishIfDirty());
}
/// <inheritdoc />
protected override void PreStart()
{
_cluster.Subscribe(
@@ -58,6 +66,7 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
typeof(ClusterEvent.ReachabilityEvent));
}
/// <inheritdoc />
protected override void PostStop() => _cluster.Unsubscribe(Self);
private void MarkDirty()
@@ -22,6 +22,9 @@ public readonly record struct NodeHealthInputs(
/// </summary>
public static class ServiceLevelCalculator
{
/// <summary>Computes the service level (0-255) based on node health inputs.</summary>
/// <param name="h">The node health inputs including cluster state, database reachability, and probe status.</param>
/// <returns>A service level byte value (0-255) indicating node authority, where higher values indicate more authoritative nodes.</returns>
public static byte Compute(NodeHealthInputs h)
{
if (h.MemberState is not (MemberStatus.Up or MemberStatus.Joining))
@@ -26,7 +26,10 @@ public static class ServiceCollectionExtensions
/// Registers all five admin-role cluster singletons + their proxies on the AkkaConfigurationBuilder.
/// Must be called against the same builder used by <c>AkkaHostedService</c> so the singletons
/// share the host's ActorSystem.
///
/// </summary>
/// <param name="builder">The Akka configuration builder.</param>
/// <returns>The builder for fluent chaining.</returns>
/// <remarks>
/// Wire from the fused Host's Program.cs:
/// <code>
/// builder.Services.AddAkka("otopcua", (ab, sp) =>
@@ -35,7 +38,7 @@ public static class ServiceCollectionExtensions
/// ab.WithOtOpcUaControlPlaneSingletons();
/// });
/// </code>
/// </summary>
/// </remarks>
public static AkkaConfigurationBuilder WithOtOpcUaControlPlaneSingletons(this AkkaConfigurationBuilder builder)
{
var singletonOptions = new ClusterSingletonOptions { Role = AdminRole };
@@ -22,6 +22,7 @@ public static class DriverFactoryBootstrap
/// Must be called BEFORE <c>services.AddAkka</c> so the runtime extension can resolve
/// <see cref="IDriverFactory"/> from DI when spawning <c>DriverHostActor</c>.
/// </summary>
/// <param name="services">The service collection to register driver factories with.</param>
public static IServiceCollection AddOtOpcUaDriverFactories(this IServiceCollection services)
{
services.AddSingleton<DriverFactoryRegistry>(sp =>
@@ -30,12 +30,20 @@ public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDis
private readonly TimeSpan _runTimeout;
private bool _disposed;
/// <summary>Initializes a new instance of the Roslyn scripted alarm evaluator.</summary>
/// <param name="logger">Logger for diagnostic messages.</param>
/// <param name="runTimeout">Optional timeout for script evaluation; defaults to 2 seconds.</param>
public RoslynScriptedAlarmEvaluator(ILogger<RoslynScriptedAlarmEvaluator> logger, TimeSpan? runTimeout = null)
{
_logger = logger;
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
}
/// <summary>Evaluates a scripted alarm predicate against provided dependencies.</summary>
/// <param name="alarmId">The alarm identifier for logging purposes.</param>
/// <param name="predicate">The predicate expression to evaluate.</param>
/// <param name="dependencies">Variables available to the predicate expression.</param>
/// <returns>Evaluation result with success flag and active state or failure reason.</returns>
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
{
if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed");
@@ -94,6 +102,7 @@ public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDis
return cache;
}
/// <summary>Disposes the evaluator and all cached script evaluators.</summary>
public void Dispose()
{
if (_disposed) return;
@@ -31,12 +31,20 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
private readonly TimeSpan _runTimeout;
private bool _disposed;
/// <summary>Initializes a new RoslynVirtualTagEvaluator with the given logger and optional timeout.</summary>
/// <param name="logger">Logger for recording compilation and execution errors.</param>
/// <param name="runTimeout">Maximum execution time for each script; defaults to 2 seconds if not specified.</param>
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
{
_logger = logger;
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
}
/// <summary>Evaluates a virtual tag expression against a set of dependencies.</summary>
/// <param name="virtualTagId">The virtual tag identifier, used for logging.</param>
/// <param name="expression">The C# expression to evaluate.</param>
/// <param name="dependencies">Dictionary of tag names to values available in the expression context.</param>
/// <returns>The evaluation result, either successful with a value or failed with an error message.</returns>
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
@@ -103,6 +111,7 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
return cache;
}
/// <summary>Disposes the evaluator and cleans up all cached script evaluators.</summary>
public void Dispose()
{
if (_disposed) return;
@@ -13,11 +13,17 @@ public sealed class AdminRoleLeaderHealthCheck : IHealthCheck
{
private readonly IClusterRoleInfo _roleInfo;
/// <summary>Initializes a new instance of the AdminRoleLeaderHealthCheck class.</summary>
/// <param name="roleInfo">The cluster role information provider.</param>
public AdminRoleLeaderHealthCheck(IClusterRoleInfo roleInfo)
{
_roleInfo = roleInfo;
}
/// <summary>Checks the health status of the admin role leader.</summary>
/// <param name="context">The health check context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the health check operation.</returns>
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (!_roleInfo.HasRole("admin"))
@@ -8,11 +8,20 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
{
private readonly ActorSystem _system;
/// <summary>
/// Initializes a new instance of the AkkaClusterHealthCheck class.
/// </summary>
/// <param name="system">The Akka actor system to check cluster health for.</param>
public AkkaClusterHealthCheck(ActorSystem system)
{
_system = system;
}
/// <summary>
/// Checks the health of the Akka cluster asynchronously.
/// </summary>
/// <param name="context">The health check context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var cluster = Akka.Cluster.Cluster.Get(_system);
@@ -8,11 +8,20 @@ public sealed class DatabaseHealthCheck : IHealthCheck
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseHealthCheck"/> class.
/// </summary>
/// <param name="dbFactory">The database context factory for the config database.</param>
public DatabaseHealthCheck(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
/// <summary>
/// Checks the health of the configuration database.
/// </summary>
/// <param name="context">The health check context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
@@ -13,6 +13,7 @@ public static class HealthEndpoints
/// probes. Mirrors ScadaLink's three-tier pattern: <c>ready</c> = boot ok; <c>active</c> =
/// fully serving traffic; <c>healthz</c> = bare process liveness.
/// </summary>
/// <param name="services">The service collection to register health checks with.</param>
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
{
services.AddHealthChecks()
@@ -22,6 +23,8 @@ public static class HealthEndpoints
return services;
}
/// <summary>Maps the OtOpcUa health check endpoints to the route builder.</summary>
/// <param name="app">The endpoint route builder.</param>
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
{
// AllowAnonymous on all three — Traefik / k8s liveness probes / load-balancers
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Observability;
/// </summary>
public static class ObservabilityExtensions
{
/// <summary>Adds OtOpcUa observability (metrics and tracing) to the service collection.</summary>
/// <param name="services">The service collection to add observability services to.</param>
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services)
{
services.AddOpenTelemetry()
@@ -30,6 +32,7 @@ public static class ObservabilityExtensions
/// <c>app.UseAuthentication/UseAuthorization</c> if metrics access should require auth;
/// the default leaves it unauthenticated for local Prometheus scrapes.
/// </summary>
/// <param name="app">The endpoint route builder.</param>
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
{
app.MapPrometheusScrapingEndpoint("/metrics");
@@ -16,6 +16,10 @@ public sealed class LdapOpcUaUserAuthenticator(
ILogger<LdapOpcUaUserAuthenticator> logger)
: IOpcUaUserAuthenticator
{
/// <summary>Authenticates an OPC UA UserName token via LDAP.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">Cancellation token.</param>
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
try
@@ -30,6 +30,14 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private OpcUaApplicationHost? _appHost;
private OtOpcUaSdkServer? _server;
/// <summary>
/// Initializes a new instance of the OtOpcUaServerHostedService class.
/// </summary>
/// <param name="configuration">The application configuration.</param>
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
public OtOpcUaServerHostedService(
IConfiguration configuration,
DeferredAddressSpaceSink deferredSink,
@@ -45,6 +53,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
}
/// <summary>
/// Starts the OPC UA server asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task StartAsync(CancellationToken cancellationToken)
{
var options = new OpcUaApplicationHostOptions();
@@ -90,6 +102,10 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
/// <summary>
/// Stops the OPC UA server asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public Task StopAsync(CancellationToken cancellationToken)
{
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
@@ -99,6 +115,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
return Task.CompletedTask;
}
/// <summary>
/// Disposes the hosted service and its resources asynchronously.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_appHost is not null) await _appHost.DisposeAsync();
@@ -22,10 +22,16 @@ public enum OpcUaSecurityProfile
Basic256Sha256SignAndEncrypt,
}
/// <summary>Configuration options for OPC UA application hosting.</summary>
public sealed class OpcUaApplicationHostOptions
{
/// <summary>Gets or sets the application name (default "OtOpcUa").</summary>
public string ApplicationName { get; set; } = "OtOpcUa";
/// <summary>Gets or sets the application URI (default "urn:OtOpcUa").</summary>
public string ApplicationUri { get; set; } = "urn:OtOpcUa";
/// <summary>Gets or sets the product URI (default "https://zb.com/otopcua").</summary>
public string ProductUri { get; set; } = "https://zb.com/otopcua";
/// <summary>Listening port for the binary endpoint (default 4840).</summary>
@@ -93,6 +99,10 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private StandardServer? _server;
private ImpersonateEventHandler? _impersonateHandler;
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
/// <param name="options">The host configuration options.</param>
/// <param name="logger">The logger for diagnostic output.</param>
/// <param name="userAuthenticator">An optional user authenticator for UserName tokens; uses null implementation if not provided.</param>
public OpcUaApplicationHost(
OpcUaApplicationHostOptions options,
ILogger<OpcUaApplicationHost> logger,
@@ -103,9 +113,15 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
}
/// <summary>Gets the OPC Foundation application instance, or null if not yet started.</summary>
public ApplicationInstance? ApplicationInstance => _application;
/// <summary>Gets the OPC UA server instance, or null if not yet started.</summary>
public StandardServer? Server => _server;
/// <summary>Starts the OPC UA application and server.</summary>
/// <param name="server">The standard server instance to start.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
{
_server = server;
@@ -215,6 +231,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
/// and logging.
/// </summary>
/// <param name="authenticator">The user authenticator to validate credentials.</param>
/// <param name="args">The impersonation event arguments to process.</param>
/// <param name="logger">The logger for diagnostic output.</param>
internal static void HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
@@ -351,6 +370,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// falls back to a single None entry so the server doesn't refuse to start with no
/// listening endpoints — the misconfiguration is logged and very visible.
/// </summary>
/// <param name="profiles">The security profiles to build policies for.</param>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
@@ -409,6 +429,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
};
}
/// <summary>Disposes the application host and cleans up resources.</summary>
public ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
@@ -30,13 +30,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
private FolderState? _root;
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
/// <param name="server">The OPC UA server instance.</param>
/// <param name="configuration">The application configuration.</param>
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, DefaultNamespaceUri)
{
// SystemContext is initialised by the base ctor.
}
/// <summary>Gets the count of variable nodes currently managed.</summary>
public int VariableCount => _variables.Count;
/// <summary>Gets the count of folder nodes currently managed.</summary>
public int FolderCount => _folders.Count;
/// <summary>
@@ -44,6 +49,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// variable node on first call; subsequent calls update Value + StatusCode +
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
/// </summary>
/// <param name="nodeId">The node identifier of the variable.</param>
/// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
@@ -61,6 +70,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>Apply an alarm-state write. Surfaced as a two-element Variable carrying
/// <c>[active, acknowledged]</c> — proper <c>AlarmConditionState</c> + event firing
/// comes when the F14b walker integration lands and registers real condition nodes.</summary>
/// <param name="alarmNodeId">The node identifier of the alarm variable.</param>
/// <param name="active">Whether the alarm is currently active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
@@ -82,6 +95,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
/// folder so adding child variables under it still works.
/// </summary>
/// <param name="folderNodeId">The node identifier of the folder.</param>
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
@@ -116,6 +132,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Idempotent. Materialises Galaxy / SystemPlatform tags so they're browseable before the
/// Galaxy driver issues SubscribeBulk.
/// </summary>
/// <param name="variableNodeId">The node identifier of the variable.</param>
/// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
@@ -28,6 +28,9 @@ public sealed class Phase7Applier
private readonly IOpcUaAddressSpaceSink _sink;
private readonly ILogger<Phase7Applier> _logger;
/// <summary>Initializes a new instance of the Phase7Applier class.</summary>
/// <param name="sink">The OPC UA address space sink to apply changes to.</param>
/// <param name="logger">The logger instance.</param>
public Phase7Applier(IOpcUaAddressSpaceSink sink, ILogger<Phase7Applier> logger)
{
ArgumentNullException.ThrowIfNull(sink);
@@ -40,6 +43,8 @@ public sealed class Phase7Applier
/// Apply <paramref name="plan"/> to the sink. Returns a summary of what was applied so
/// callers (OpcUaPublishActor) can correlate the work back to the originating deployment.
/// </summary>
/// <param name="plan">The plan to apply.</param>
/// <returns>A Phase7ApplyOutcome summarizing the applied changes.</returns>
public Phase7ApplyOutcome Apply(Phase7Plan plan)
{
ArgumentNullException.ThrowIfNull(plan);
@@ -104,6 +109,7 @@ public sealed class Phase7Applier
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
/// present, so re-applies are cheap.
/// </summary>
/// <param name="composition">The composition result containing the hierarchy to materialise.</param>
public void MaterialiseHierarchy(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
@@ -136,6 +142,7 @@ public sealed class Phase7Applier
/// the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
/// Idempotent.
/// </summary>
/// <param name="composition">The composition result containing the Galaxy tags to materialise.</param>
public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
@@ -18,6 +18,9 @@ public sealed record Phase7CompositionResult(
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
{
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary>
/// <param name="equipmentNodes">The equipment nodes.</param>
/// <param name="driverInstancePlans">The driver instance plans.</param>
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
public Phase7CompositionResult(
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
@@ -28,6 +31,11 @@ public sealed record Phase7CompositionResult(
}
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipmentNodes">The equipment nodes.</param>
/// <param name="driverInstancePlans">The driver instance plans.</param>
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
public Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> unsAreas,
IReadOnlyList<UnsLineProjection> unsLines,
@@ -74,6 +82,10 @@ public sealed record GalaxyTagPlan(
public static class Phase7Composer
{
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <returns>The composition result.</returns>
public static Phase7CompositionResult Compose(
IReadOnlyList<Equipment> equipment,
IReadOnlyList<DriverInstance> driverInstances,
@@ -82,6 +94,12 @@ public static class Phase7Composer
Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <returns>The composition result.</returns>
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
@@ -91,6 +109,17 @@ public static class Phase7Composer
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>
/// Composes the address space build plan from the configuration entities.
/// </summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="tags">The tags.</param>
/// <param name="namespaces">The namespaces.</param>
/// <returns>The composition result.</returns>
public static Phase7CompositionResult Compose(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
@@ -26,6 +26,7 @@ public sealed record Phase7Plan(
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
{
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
public bool IsEmpty =>
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
@@ -46,6 +47,8 @@ public static class Phase7Planner
/// Element equality on the projection records doubles as the "did this change" check,
/// so any field difference moves an item from "stable" to ChangedX.
/// </summary>
/// <param name="previous">The previous composition result.</param>
/// <param name="next">The new composition result.</param>
public static Phase7Plan Compute(Phase7CompositionResult previous, Phase7CompositionResult next)
{
ArgumentNullException.ThrowIfNull(previous);
@@ -12,23 +12,45 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
{
private readonly OtOpcUaNodeManager _nodeManager;
/// <summary>Initializes a new instance of the SdkAddressSpaceSink class.</summary>
/// <param name="nodeManager">The OPC UA node manager.</param>
public SdkAddressSpaceSink(OtOpcUaNodeManager nodeManager)
{
ArgumentNullException.ThrowIfNull(nodeManager);
_nodeManager = nodeManager;
}
/// <summary>Writes a value to the OPC UA address space.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
/// <summary>Writes alarm state to the OPC UA address space.</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm is acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
/// <summary>Ensures a folder node exists in the address space.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
/// <summary>Ensures a variable node exists in the address space.</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA data type.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
/// <summary>Rebuilds the entire OPC UA address space.</summary>
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
}
@@ -23,12 +23,17 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
private readonly IServerInternal _serverInternal;
private readonly ILogger<SdkServiceLevelPublisher> _logger;
/// <summary>Initializes a new instance of the SdkServiceLevelPublisher class.</summary>
/// <param name="serverInternal">The OPC UA server internal interface.</param>
/// <param name="logger">The logger instance.</param>
public SdkServiceLevelPublisher(IServerInternal serverInternal, ILogger<SdkServiceLevelPublisher> logger)
{
_serverInternal = serverInternal;
_logger = logger;
}
/// <summary>Publishes the service level to the OPC UA Server object.</summary>
/// <param name="serviceLevel">The service level value to publish.</param>
public void Publish(byte serviceLevel)
{
var node = _serverInternal.ServerObject?.ServiceLevel;
@@ -17,6 +17,10 @@ public interface IOpcUaUserAuthenticator
/// reject codes, and a thrown exception escapes into the OPC UA SDK's session-activation
/// path where it surfaces as a generic <c>BadInternalError</c>.
/// </summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An OpcUaUserAuthResult indicating success or failure.</returns>
Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct);
}
@@ -27,9 +31,16 @@ public sealed record OpcUaUserAuthResult(
IReadOnlyList<string> Roles,
string? Error)
{
/// <summary>Creates a successful authentication result with the specified display name and roles.</summary>
/// <param name="displayName">The display name for the authenticated user.</param>
/// <param name="roles">The roles to assign to the authenticated user.</param>
/// <returns>A successful OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult Allow(string displayName, IReadOnlyList<string> roles) =>
new(true, displayName, roles, null);
/// <summary>Creates a failed authentication result with the specified error message.</summary>
/// <param name="error">The error message describing why authentication failed.</param>
/// <returns>A failed OpcUaUserAuthResult.</returns>
public static OpcUaUserAuthResult Deny(string error) =>
new(false, null, Array.Empty<string>(), error);
}
@@ -44,6 +55,11 @@ public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
public static readonly NullOpcUaUserAuthenticator Instance = new();
private NullOpcUaUserAuthenticator() { }
/// <summary>Authenticates a username (always denies in this null implementation).</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns a denial result indicating no authenticator is configured.</returns>
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
}
@@ -28,6 +28,7 @@ public static class DeploymentArtifact
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
@@ -87,6 +88,7 @@ public static class DeploymentArtifact
/// subset of fields per entity class to drive the address-space rebuild on driver-role
/// nodes.
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
public static Phase7CompositionResult ParseComposition(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty) return Empty();
@@ -54,6 +54,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private sealed record ChildEntry(IActorRef Actor, string DriverType, string LastConfigJson, bool Stubbed);
/// <inheritdoc />
public ITimerScheduler Timers { get; set; } = null!;
public sealed class RetryConfigDbConnection
@@ -62,6 +63,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private RetryConfigDbConnection() { }
}
/// <summary>Creates props for a DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
/// <param name="localNode">The local cluster node identifier.</param>
/// <param name="coordinator">Optional coordinator actor reference for deployment coordination.</param>
/// <param name="driverFactory">Optional driver factory; defaults to null factory if not provided.</param>
/// <param name="localRoles">Optional set of roles assigned to the local node.</param>
/// <param name="dependencyMux">Optional actor reference for dependency multiplexing.</param>
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -73,6 +82,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Akka.Actor.Props.Create(() => new DriverHostActor(
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
/// <param name="localNode">The local cluster node identifier.</param>
/// <param name="coordinator">Optional coordinator actor reference for deployment coordination.</param>
/// <param name="driverFactory">Optional driver factory; defaults to null factory if not provided.</param>
/// <param name="localRoles">Optional set of roles assigned to the local node.</param>
/// <param name="dependencyMux">Optional actor reference for dependency multiplexing.</param>
/// <param name="opcUaPublishActor">Optional actor reference for OPC UA publishing.</param>
public DriverHostActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -94,6 +111,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Become(Steady);
}
/// <inheritdoc />
protected override void PreStart()
{
// Subscribe to deployments topic so the coordinator's broadcast lands here.
@@ -389,14 +407,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary>
private sealed class StubbedDriver : IDriver
{
/// <inheritdoc />
public string DriverInstanceId { get; }
/// <inheritdoc />
public string DriverType { get; }
/// <summary>Initializes a new stubbed driver with the specified ID and type.</summary>
/// <param name="id">The driver instance identifier.</param>
/// <param name="type">The driver type name.</param>
public StubbedDriver(string id, string type) { DriverInstanceId = id; DriverType = type; }
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, LastError: null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -59,8 +59,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private ISubscriptionHandle? _subscriptionHandle;
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
/// <summary>
/// Gets or sets the timer scheduler for scheduling reconnection attempts.
/// </summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>
/// Creates a Props object for instantiating a <see cref="DriverInstanceActor"/>.
/// </summary>
/// <param name="driver">The driver instance to wrap.</param>
/// <param name="reconnectInterval">Optional interval for reconnection attempts; defaults to 10 seconds.</param>
/// <param name="startStubbed">If true, the actor starts in stub mode for testing or unavailable platforms.</param>
public static Props Props(IDriver driver, TimeSpan? reconnectInterval = null, bool startStubbed = false) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(driver, reconnectInterval ?? DefaultReconnectInterval, startStubbed));
@@ -76,6 +85,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// The v2 <c>"GalaxyMxGateway"</c> driver talks gRPC to an external mxaccessgw process,
/// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed.
/// </summary>
/// <param name="driverType">The type identifier of the driver.</param>
/// <param name="roles">Operational roles configured for this instance.</param>
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
{
var isWindowsOnly = driverType is "Galaxy" or "Historian.Wonderware";
@@ -84,6 +95,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
return false;
}
/// <summary>
/// Initializes a new instance of the <see cref="DriverInstanceActor"/> class.
/// </summary>
/// <param name="driver">The driver instance to wrap and manage.</param>
/// <param name="reconnectInterval">Interval between reconnection attempts.</param>
/// <param name="startStubbed">If true, start in stub mode for testing or unavailable platforms.</param>
public DriverInstanceActor(IDriver driver, TimeSpan reconnectInterval, bool startStubbed = false)
{
_driver = driver;
@@ -319,6 +336,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private static bool IsGoodStatus(uint statusCode) => (statusCode >> 30) == 0;
/// <inheritdoc />
protected override void PostStop()
{
DetachSubscription();
@@ -21,6 +21,8 @@ public static class DriverSpawnPlanner
/// treated as "not desired here": if a child exists for the id it goes into ToStop,
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
/// </summary>
/// <param name="current">The currently running driver children keyed by ID.</param>
/// <param name="target">The target driver instances from the deployment artifact.</param>
public static DriverSpawnPlan Compute(
IReadOnlyDictionary<string, DriverChildSnapshot> current,
IReadOnlyList<DriverInstanceSpec> target)
@@ -23,11 +23,16 @@ public sealed class DbHealthProbeActor : ReceiveActor, IWithTimers
private readonly ILoggingAdapter _log = Context.GetLogger();
private DbHealthStatus _last = new(false, DateTime.MinValue, "not probed yet");
/// <summary>Gets or sets the timer scheduler for periodic health probes.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates a Props instance for the DbHealthProbeActor.</summary>
/// <param name="dbFactory">The factory for creating ConfigDb contexts.</param>
public static Props Props(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) =>
Akka.Actor.Props.Create(() => new DbHealthProbeActor(dbFactory));
/// <summary>Initializes a new instance of the <see cref="DbHealthProbeActor"/> class.</summary>
/// <param name="dbFactory">The factory for creating ConfigDb contexts.</param>
public DbHealthProbeActor(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{
_dbFactory = dbFactory;
@@ -35,6 +40,7 @@ public sealed class DbHealthProbeActor : ReceiveActor, IWithTimers
Receive<Tick>(_ => RunProbe());
}
/// <inheritdoc />
protected override void PreStart()
{
RunProbe();
@@ -37,8 +37,16 @@ public sealed class PeerOpcUaProbeActor : ReceiveActor, IWithTimers
private readonly Action<object>? _broadcastOverride;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Gets or sets the timer scheduler for this actor.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates actor props for the peer OPC UA probe.</summary>
/// <param name="peer">The node identifier of the peer to probe.</param>
/// <param name="interval">Optional probe interval; defaults to DefaultProbeInterval.</param>
/// <param name="connectTimeout">Optional connection timeout; defaults to DefaultConnectTimeout.</param>
/// <param name="opcUaPort">The OPC UA port to connect to; defaults to DefaultOpcUaPort.</param>
/// <param name="broadcast">Optional custom broadcast delegate for probe results.</param>
/// <returns>Props configured to create a PeerOpcUaProbeActor.</returns>
public static Props Props(
NodeId peer,
TimeSpan? interval = null,
@@ -52,6 +60,12 @@ public sealed class PeerOpcUaProbeActor : ReceiveActor, IWithTimers
opcUaPort,
broadcast));
/// <summary>Initializes a new instance of the PeerOpcUaProbeActor.</summary>
/// <param name="peer">The node identifier of the peer to probe.</param>
/// <param name="interval">The probe interval.</param>
/// <param name="connectTimeout">The connection timeout.</param>
/// <param name="opcUaPort">The OPC UA port to connect to.</param>
/// <param name="broadcastOverride">Optional custom broadcast delegate for probe results.</param>
public PeerOpcUaProbeActor(
NodeId peer,
TimeSpan interval,
@@ -68,6 +82,7 @@ public sealed class PeerOpcUaProbeActor : ReceiveActor, IWithTimers
ReceiveAsync<Tick>(_ => RunProbeAsync());
}
/// <inheritdoc />
protected override void PreStart() =>
Timers.StartPeriodicTimer("probe", Tick.Instance, _interval);
@@ -25,9 +25,14 @@ public sealed class HistorianAdapterActor : ReceiveActor
private readonly IAlarmHistorianSink _sink;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates the props for a HistorianAdapterActor instance.</summary>
/// <param name="sink">The alarm historian sink implementation, or null to use a null sink.</param>
/// <returns>Props configured for creating a HistorianAdapterActor.</returns>
public static Props Props(IAlarmHistorianSink? sink = null) =>
Akka.Actor.Props.Create(() => new HistorianAdapterActor(sink ?? NullAlarmHistorianSink.Instance));
/// <summary>Initializes a new instance of the HistorianAdapterActor class.</summary>
/// <param name="sink">The alarm historian sink to forward enqueued events to.</param>
public HistorianAdapterActor(IAlarmHistorianSink sink)
{
_sink = sink;
@@ -52,7 +52,9 @@ public sealed class OpcUaPublishActor : ReceiveActor
Array.Empty<ScriptedAlarmPlan>(),
Array.Empty<GalaxyTagPlan>());
/// <summary>Gets the number of writes performed.</summary>
public int WriteCount => _writes;
/// <summary>Gets the last published service level.</summary>
public byte LastServiceLevel => _lastServiceLevel;
/// <summary>Production Props — pins the OPC UA dispatcher + subscribes to the
@@ -60,6 +62,11 @@ public sealed class OpcUaPublishActor : ReceiveActor
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
/// applier through the sink.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
/// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param>
public static Props Props(
IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null,
@@ -76,6 +83,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
/// <param name="subscribeRedundancyTopic">Whether to subscribe to the redundancy topic.</param>
/// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param>
public static Props PropsForTests(
IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null,
@@ -91,6 +104,13 @@ public sealed class OpcUaPublishActor : ReceiveActor
dbFactory,
applier));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param>
/// <param name="subscribeRedundancyTopic">Whether to subscribe to the redundancy topic.</param>
/// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param>
public OpcUaPublishActor(
IOpcUaAddressSpaceSink sink,
IServiceLevelPublisher serviceLevel,
@@ -114,6 +134,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
/// <inheritdoc />
protected override void PreStart()
{
if (_subscribeRedundancyTopic)
@@ -19,6 +19,9 @@ public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly ILogger<EfAlarmActorStateStore> _logger;
/// <summary>Initializes a new instance of the EfAlarmActorStateStore.</summary>
/// <param name="dbFactory">The factory for creating database contexts.</param>
/// <param name="logger">The logger instance.</param>
public EfAlarmActorStateStore(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
ILogger<EfAlarmActorStateStore> logger)
@@ -27,6 +30,10 @@ public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
_logger = logger;
}
/// <summary>Loads the alarm state snapshot from the database.</summary>
/// <param name="alarmId">The identifier of the alarm.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The alarm state snapshot, or null if not found.</returns>
public async Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
@@ -43,6 +50,9 @@ public sealed class EfAlarmActorStateStore : IAlarmActorStateStore
LastAckUser: row.LastAckUser);
}
/// <summary>Saves the alarm state snapshot to the database.</summary>
/// <param name="snapshot">The alarm state snapshot to save.</param>
/// <param name="ct">The cancellation token.</param>
public async Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
@@ -49,6 +49,12 @@ public sealed class ScriptedAlarmActor : ReceiveActor
public sealed record StateRestored(ScriptedAlarmActorState State, string? LastAckUser);
/// <summary>Creates a new Props for a ScriptedAlarmActor with the given configuration and optional dependencies.</summary>
/// <param name="config">The alarm configuration.</param>
/// <param name="evaluator">The alarm evaluator; defaults to null evaluator if not provided.</param>
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
/// <param name="stateStore">Optional state store for persistence; defaults to null store if not provided.</param>
/// <returns>Akka Props for creating the actor.</returns>
public static Props Props(
AlarmConfig config,
IScriptedAlarmEvaluator? evaluator = null,
@@ -62,9 +68,16 @@ public sealed class ScriptedAlarmActor : ReceiveActor
/// <summary>Legacy single-arg ctor kept for callers that only care about the state machine
/// (no engine evaluation, no DPS fan-out, no persistence). Equivalent to <c>Props(new AlarmConfig(...))</c>.</summary>
/// <param name="alarmId">The alarm identifier, used as both alarm ID and name.</param>
/// <returns>Akka Props for creating the actor with minimal configuration.</returns>
public static Props Props(string alarmId) =>
Props(new AlarmConfig(alarmId, alarmId, EquipmentPath: "", Severity: 500, Predicate: null));
/// <summary>Initializes a new ScriptedAlarmActor with the given configuration and dependencies.</summary>
/// <param name="config">The alarm configuration.</param>
/// <param name="evaluator">The alarm predicate evaluator.</param>
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
/// <param name="stateStore">The state store for loading and saving alarm state.</param>
public ScriptedAlarmActor(
AlarmConfig config,
IScriptedAlarmEvaluator evaluator,
@@ -83,6 +96,7 @@ public sealed class ScriptedAlarmActor : ReceiveActor
Receive<StateRestored>(OnStateRestored);
}
/// <inheritdoc />
protected override void PreStart()
{
// Load persisted state — when the store has a row, restore in-memory state before the
@@ -35,6 +35,7 @@ public static class ServiceCollectionExtensions
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping <c>WonderwareHistorianClient</c>.
/// Call this BEFORE <c>AddAkka</c>.
/// </summary>
/// <param name="services">The service collection to register with.</param>
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
@@ -60,6 +61,7 @@ public static class ServiceCollectionExtensions
/// services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasDriver) ab.WithOtOpcUaRuntimeActors(); });
/// </code>
/// </summary>
/// <param name="builder">The Akka configuration builder.</param>
public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
{
// Production cluster HOCON (akka.conf) carries this dispatcher block, but consumers that
@@ -33,8 +33,11 @@ public sealed class DependencyMuxActor : ReceiveActor
private readonly Dictionary<IActorRef, HashSet<string>> _bySubscriber = new();
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates props for the DependencyMuxActor.</summary>
/// <returns>The props for creating this actor.</returns>
public static Props Props() => Akka.Actor.Props.Create<DependencyMuxActor>();
/// <summary>Initializes a new instance of the <see cref="DependencyMuxActor"/> class.</summary>
public DependencyMuxActor()
{
Receive<RegisterInterest>(OnRegister);
@@ -35,6 +35,14 @@ public sealed class VirtualTagActor : ReceiveActor
private bool _hasLastValue;
private object? _lastValue;
/// <summary>Factory method to create Props for a VirtualTagActor.</summary>
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
/// <param name="expression">The expression to evaluate.</param>
/// <param name="evaluator">Optional evaluator; defaults to a null-object instance.</param>
/// <param name="scriptId">Optional script identifier; defaults to <paramref name="virtualTagId"/>.</param>
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
/// <param name="dependencyRefs">Optional list of dependency tag references; defaults to empty.</param>
/// <param name="mux">Optional reference to a dependency multiplexer actor.</param>
public static Props Props(
string virtualTagId,
string expression,
@@ -51,6 +59,14 @@ public sealed class VirtualTagActor : ReceiveActor
dependencyRefs ?? Array.Empty<string>(),
mux));
/// <summary>Initializes a virtual tag actor with the given configuration and dependencies.</summary>
/// <param name="virtualTagId">Unique identifier for the virtual tag.</param>
/// <param name="expression">The expression to evaluate.</param>
/// <param name="evaluator">The evaluator responsible for computing the expression result.</param>
/// <param name="scriptId">The script identifier associated with this tag.</param>
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
/// <param name="dependencyRefs">List of dependency tag references that this tag depends on.</param>
/// <param name="mux">Optional reference to a dependency multiplexer actor.</param>
public VirtualTagActor(
string virtualTagId,
string expression,
@@ -71,6 +87,7 @@ public sealed class VirtualTagActor : ReceiveActor
Receive<DependencyValueChanged>(OnDependencyChanged);
}
/// <inheritdoc />
protected override void PreStart()
{
if (_mux is not null && _dependencyRefs.Count > 0)
@@ -79,6 +96,7 @@ public sealed class VirtualTagActor : ReceiveActor
}
}
/// <inheritdoc />
protected override void PostStop()
{
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
@@ -20,6 +20,10 @@ public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvi
private ClaimsPrincipal _current;
private Task? _pingLoop;
/// <summary>Initializes a new instance of the CookieAuthenticationStateProvider class.</summary>
/// <param name="initial">The initial claims principal from circuit boot.</param>
/// <param name="http">The HTTP client for authentication ping requests.</param>
/// <param name="logger">The logger for diagnostic messages.</param>
public CookieAuthenticationStateProvider(
ClaimsPrincipal initial,
HttpClient http,
@@ -30,6 +34,7 @@ public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvi
_logger = logger;
}
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
_pingLoop ??= Task.Run(() => PingLoopAsync(_cts.Token));
@@ -60,6 +65,7 @@ public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvi
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_cts.Cancel();
@@ -4,6 +4,7 @@ public sealed class OtOpcUaCookieOptions
{
public const string SectionName = "Security:Cookie";
/// <summary>Gets or sets the cookie name.</summary>
public string Name { get; set; } = "OtOpcUa.Auth";
/// <summary>Idle sliding window, in minutes (default 30).</summary>
@@ -16,8 +16,12 @@ public static class AuthEndpoints
/// <summary>JSON body schema for API-side login callers (kept stable for tests).</summary>
public sealed record LoginRequest(string Username, string Password);
/// <summary>Response for a token issue request.</summary>
public sealed record TokenResponse(string Token);
/// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapOtOpcUaAuth(this IEndpointRouteBuilder app)
{
// The login endpoint serves two callers with different ergonomics:
@@ -8,7 +8,9 @@ public sealed class JwtOptions
/// <summary>HS256 signing key. Must be at least 32 bytes (256 bits) UTF-8.</summary>
public string SigningKey { get; set; } = string.Empty;
/// <summary>Gets or sets the JWT issuer identifier.</summary>
public string Issuer { get; set; } = "otopcua";
/// <summary>Gets or sets the JWT audience identifier.</summary>
public string Audience { get; set; } = "otopcua";
/// <summary>Default token expiry. Mirrors ScadaLink (15 min).</summary>
@@ -16,6 +16,9 @@ public sealed class JwtTokenService
private readonly JwtOptions _options;
private readonly ILogger<JwtTokenService> _logger;
/// <summary>Initializes a new instance of the JwtTokenService class.</summary>
/// <param name="options">The JWT options.</param>
/// <param name="logger">The logger for the service.</param>
public JwtTokenService(IOptions<JwtOptions> options, ILogger<JwtTokenService> logger)
{
_options = options.Value;
@@ -32,6 +35,11 @@ public sealed class JwtTokenService
}
}
/// <summary>Issues a JWT token for the specified user with the given roles.</summary>
/// <param name="displayName">The display name of the user.</param>
/// <param name="username">The username of the user.</param>
/// <param name="roles">The roles assigned to the user.</param>
/// <returns>The JWT token string.</returns>
public string Issue(string displayName, string username, IReadOnlyList<string> roles)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
@@ -55,6 +63,10 @@ public sealed class JwtTokenService
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Attempts to validate a JWT token and extract the claims principal.</summary>
/// <param name="token">The JWT token to validate.</param>
/// <param name="principal">The claims principal extracted from the token, or null if validation failed.</param>
/// <returns>True if the token is valid; otherwise false.</returns>
public bool TryValidate(string token, out ClaimsPrincipal? principal)
{
principal = null;
@@ -2,5 +2,10 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
public interface ILdapAuthService
{
/// <summary>Authenticates a user against the LDAP service.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to verify.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>A task that returns the authentication result.</returns>
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
}
@@ -15,6 +15,10 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
{
private readonly LdapOptions _options = options.Value;
/// <summary>Authenticates a user via LDAP bind and retrieves their group memberships and roles.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to validate against the LDAP directory.</param>
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
@@ -130,6 +134,9 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
: $"cn={username},{_options.SearchBase}";
}
/// <summary>Escapes special characters in an LDAP filter string according to RFC 4515.</summary>
/// <param name="input">The unescaped string to escape.</param>
/// <returns>The escaped LDAP filter string.</returns>
internal static string EscapeLdapFilter(string input) =>
input.Replace("\\", "\\5c")
.Replace("*", "\\2a")
@@ -142,6 +149,8 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
/// </summary>
/// <param name="dn">The distinguished name to extract the OU from.</param>
/// <returns>The extracted OU value, or null if no OU segment is found.</returns>
internal static string? ExtractOuSegment(string dn)
{
var segments = dn.Split(',');
@@ -154,6 +163,9 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
return null;
}
/// <summary>Extracts the value portion of the first RDN (relative distinguished name) from a DN.</summary>
/// <param name="dn">The distinguished name to extract from.</param>
/// <returns>The value of the first RDN.</returns>
internal static string ExtractFirstRdnValue(string dn)
{
var equalsIdx = dn.IndexOf('=');
@@ -9,9 +9,16 @@ public sealed class LdapOptions
{
public const string SectionName = "Authentication:Ldap";
/// <summary>Gets or sets a value indicating whether LDAP authentication is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Gets or sets the LDAP server hostname.</summary>
public string Server { get; set; } = "localhost";
/// <summary>Gets or sets the LDAP server port.</summary>
public int Port { get; set; } = 3893;
/// <summary>Gets or sets a value indicating whether to use TLS for LDAP connection.</summary>
public bool UseTls { get; set; }
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
@@ -24,6 +31,7 @@ public sealed class LdapOptions
/// </summary>
public bool DevStubMode { get; set; }
/// <summary>Gets or sets the LDAP search base DN.</summary>
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
@@ -31,9 +39,14 @@ public sealed class LdapOptions
/// <c>cn={user},{SearchBase}</c> is attempted.
/// </summary>
public string ServiceAccountDn { get; set; } = string.Empty;
/// <summary>Gets or sets the service account password for LDAP authentication.</summary>
public string ServiceAccountPassword { get; set; } = string.Empty;
/// <summary>Gets or sets the LDAP attribute name for user display name.</summary>
public string DisplayNameAttribute { get; set; } = "cn";
/// <summary>Gets or sets the LDAP attribute name for group membership.</summary>
public string GroupAttribute { get; set; } = "memberOf";
/// <summary>
@@ -6,6 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// </summary>
public static class RoleMapper
{
/// <summary>Maps LDAP groups to roles using the provided group-to-role mapping dictionary.</summary>
/// <param name="ldapGroups">The LDAP groups to map.</param>
/// <param name="groupToRole">The mapping dictionary from LDAP groups to roles.</param>
/// <returns>The list of roles corresponding to the LDAP groups.</returns>
public static IReadOnlyList<string> Map(
IReadOnlyCollection<string> ldapGroups,
IReadOnlyDictionary<string, string> groupToRole)
@@ -21,6 +21,9 @@ namespace ZB.MOM.WW.OtOpcUa.Security;
internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService)
: IPostConfigureOptions<JwtBearerOptions>
{
/// <summary>Configures JWT bearer options from the token service.</summary>
/// <param name="name">The options name.</param>
/// <param name="options">The JWT bearer options to configure.</param>
public void PostConfigure(string? name, JwtBearerOptions options)
{
if (name != JwtBearerDefaults.AuthenticationScheme) return;
@@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions
/// tools, scripts). DataProtection keys persist to the shared ConfigDb so cookies survive
/// failover between nodes.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The application configuration.</param>
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));