feat(worker): route alarm acks through the operator-authenticated AckMsg writer
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
@@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
private readonly Action? threadAffinityCheck;
|
||||
private readonly IMxAccessComObjectFactory? comFactory;
|
||||
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
|
||||
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
|
||||
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
|
||||
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
|
||||
private readonly object syncRoot = new object();
|
||||
private readonly IAlarmAckWriter deferredAckWriter;
|
||||
private AlarmDispatcher? dispatcher;
|
||||
private IAlarmAckWriter? ackWriter;
|
||||
private FailoverAlarmConsumer? failoverConsumer;
|
||||
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
|
||||
private string subscribeSessionId = string.Empty;
|
||||
@@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
|
||||
/// live COM factory.
|
||||
/// </param>
|
||||
/// <param name="managedAckFactory">
|
||||
/// Optional per-subscription factory for a managed ack client
|
||||
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
|
||||
/// client, GUID-keyed acknowledges route through AVEVA's modern
|
||||
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
|
||||
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
|
||||
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
|
||||
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
|
||||
/// <param name="ackWriterFactory">
|
||||
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
|
||||
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
|
||||
/// <paramref name="comFactory"/> that registers its own MXAccess server
|
||||
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
|
||||
/// The writer is created once per handler, on the first acknowledgement
|
||||
/// (so a subscription never fails because ack is unavailable), survives
|
||||
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
|
||||
/// inject a fake.
|
||||
/// </param>
|
||||
public AlarmCommandHandler(
|
||||
MxAccessEventQueue eventQueue,
|
||||
@@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
Action? threadAffinityCheck,
|
||||
IMxAccessComObjectFactory? comFactory,
|
||||
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
|
||||
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
|
||||
Func<IAlarmAckWriter>? ackWriterFactory = null)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
||||
this.threadAffinityCheck = threadAffinityCheck;
|
||||
this.comFactory = comFactory;
|
||||
this.standbyFactory = standbyFactory;
|
||||
this.managedAckFactory = managedAckFactory;
|
||||
this.ackWriterFactory = ackWriterFactory;
|
||||
this.deferredAckWriter = new DeferredAckWriter(this);
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
|
||||
@@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
failover.ProviderModeChanged += providerModeChangedHandler;
|
||||
}
|
||||
|
||||
// Optional managed ack client (opt-in via env); null → wnwrap ack.
|
||||
// A forced-subtag session never has alarmmgr GUIDs to ack, so it
|
||||
// skips the extra managed consumer entirely.
|
||||
IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag
|
||||
? null
|
||||
: managedAckFactory?.Invoke();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
|
||||
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck);
|
||||
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
|
||||
try
|
||||
{
|
||||
dispatcher.Subscribe(subscriptionExpression);
|
||||
@@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(
|
||||
alarmGuid,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
public AlarmAckResult AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||
return d.AcknowledgeByName(
|
||||
alarmName ?? string.Empty,
|
||||
providerName ?? string.Empty,
|
||||
groupName ?? string.Empty,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
ackOperator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
d?.PollOnce();
|
||||
}
|
||||
|
||||
private IAlarmAckWriter GetOrCreateAckWriter()
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
if (ackWriter is not null) return ackWriter;
|
||||
if (ackWriterFactory is not null)
|
||||
{
|
||||
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
|
||||
return ackWriter;
|
||||
}
|
||||
|
||||
if (comFactory is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
|
||||
}
|
||||
|
||||
// StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on
|
||||
// the calling thread — the STA, since acks run there — which is what
|
||||
// delivers the writer's OnWriteComplete callback.
|
||||
ackWriter = new AlarmAckWriter(
|
||||
comFactory,
|
||||
() => new StaMessagePump().PumpPendingMessages(),
|
||||
MxAccessStaSession.ResolveWriteCompletionTimeout(),
|
||||
AlarmAckWriter.ResolveClientName());
|
||||
return ackWriter;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
|
||||
/// handler's real writer on first acknowledgement so the writer (and its
|
||||
/// MXAccess server registration) is created only when an ack happens and
|
||||
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
|
||||
/// the real writer.
|
||||
/// </summary>
|
||||
private sealed class DeferredAckWriter : IAlarmAckWriter
|
||||
{
|
||||
private readonly AlarmCommandHandler owner;
|
||||
|
||||
public DeferredAckWriter(AlarmCommandHandler owner)
|
||||
{
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
|
||||
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private AlarmDispatcher GetDispatcherOrThrow()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
@@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
Unsubscribe();
|
||||
IAlarmAckWriter? writerToDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
writerToDispose = ackWriter;
|
||||
ackWriter = null;
|
||||
}
|
||||
|
||||
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user