From d6db0ff40d08e3d45e8dbb03aa52070e41ca6ebd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 19 Oct 2026 19:35:55 -0400 Subject: [PATCH] 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 --- .../MxAccess/AlarmCommandExecutorTests.cs | 216 +++++++++++-- .../MxAccess/AlarmCommandHandlerTests.cs | 211 +++++++++---- .../MxAccess/AlarmDispatcherTests.cs | 286 +++++++----------- .../MxAccess/MxAccessStaSessionTests.cs | 12 +- .../AlarmManagedGuidAckLiveSmokeTests.cs | 229 -------------- .../Probes/AlarmsLiveSmokeTests.cs | 40 +-- .../Ipc/WorkerPipeSession.cs | 6 +- .../MxAccess/AaManagedAlarmAckConsumer.cs | 231 -------------- .../MxAccess/AlarmCommandHandler.cs | 131 +++++--- .../MxAccess/AlarmDispatcher.cs | 144 +++------ .../MxAccess/IAlarmCommandHandler.cs | 45 +-- .../MxAccess/IManagedAlarmAckClient.cs | 44 --- .../MxAccess/MxAccessCommandExecutor.cs | 109 ++++--- .../MxAccess/WnalNativeAckConsumer.cs | 273 ----------------- 14 files changed, 695 insertions(+), 1282 deletions(-) delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs delete mode 100644 src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index 4b82047..1ce7860 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests [Fact] public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; + FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); Guid g = Guid.NewGuid(); @@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests Assert.NotNull(reply.AcknowledgeAlarm); Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus); Assert.Equal(g, handler.LastAckGuid); - Assert.Equal("alice", handler.LastAckOperatorName); + Assert.Equal("ack", handler.LastComment); + Assert.Equal("alice", handler.LastOperator!.UserName); + Assert.Equal("CORP", handler.LastOperator.Domain); + Assert.Equal(string.Empty, handler.LastOperator.Password); } /// Verifies that invalid alarm GUID returns invalid request. @@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code); } - /// Verifies that nonzero native status carries a diagnostic message. + /// Verifies that a rejected write carries its native status and diagnostic. [Fact] public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 }; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."), + }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( @@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests MxCommandReply reply = executor.Execute(command); + Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); Assert.Equal(-123, reply.Hresult); + Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus); Assert.Contains("-123", reply.DiagnosticMessage); } @@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests [Fact] public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 }; + FakeAlarmHandler handler = new FakeAlarmHandler(); MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( @@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name); Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider); Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group); - Assert.Equal("alice", handler.LastAckOperatorName); + Assert.Equal("ack", handler.LastComment); + Assert.Equal("alice", handler.LastOperator!.UserName); + } + + /// + /// Every ack outcome maps to its protocol status, carries the native + /// status, and never echoes the operator password. + /// + /// The writer outcome. + /// The expected protocol status. + [Theory] + [InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)] + [InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)] + [InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)] + [InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)] + [InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)] + [InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)] + public void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected) + { + int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand + { + AlarmGuid = Guid.NewGuid().ToString(), + Comment = "checked", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "pw-secret", + }, + }); + + AssertAckReply(reply, handler, expected, native); + } + + /// The by-name ack maps outcomes identically and never echoes the operator password. + /// The writer outcome. + /// The expected protocol status. + [Theory] + [InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)] + [InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)] + [InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)] + [InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)] + [InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)] + [InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)] + public void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected) + { + int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = "TestMachine_001.TestAlarm001", + ProviderName = "Galaxy", + GroupName = "TestArea", + Comment = "checked", + OperatorUser = "alice", + OperatorDomain = "CORP", + OperatorPassword = "pw-secret", + }, + }); + + AssertAckReply(reply, handler, expected, native); + } + + /// The writer's OnWriteComplete statuses are copied onto the reply. + [Fact] + public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply() + { + MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 }; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarm, + AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" }, + }); + + Assert.Equal(status, Assert.Single(reply.Statuses)); + } + + /// + /// An exception escaping the ack path (e.g. the writer's MXAccess + /// Register failing) maps to MxaccessFailure with only the exception type + /// and HRESULT — never the exception text, which is not under our control. + /// + [Fact] + public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly() + { + FakeAlarmHandler handler = new FakeAlarmHandler + { + AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)), + }; + + MxCommandReply reply = Execute(handler, new MxCommand + { + Kind = MxCommandKind.AcknowledgeAlarmByName, + AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand + { + AlarmName = "TestMachine_001.TestAlarm001", + ProviderName = "Galaxy", + OperatorUser = "alice", + OperatorPassword = "pw-secret", + }, + }); + + Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); + Assert.Equal(unchecked((int)0x80004005), reply.Hresult); + Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal); } /// Verifies that empty alarm name returns invalid request. @@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests [Fact] public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure() { - FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true }; + FakeAlarmHandler handler = new FakeAlarmHandler + { + AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"), + }; MxAccessCommandExecutor executor = NewExecutor(handler); StaCommand command = new StaCommand( @@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests MxCommandReply reply = executor.Execute(command); Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code); - Assert.Contains("simulated", reply.DiagnosticMessage); + Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal); + Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal); + } + + private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) => + NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command)); + + private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native) + { + Assert.Equal(expected, reply.ProtocolStatus.Code); + Assert.Equal(native, reply.Hresult); + Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus); + Assert.Equal("diagnostic for alice", reply.DiagnosticMessage); + Assert.Equal("checked", handler.LastComment); + Assert.Equal("alice", handler.LastOperator!.UserName); + Assert.Equal("CORP", handler.LastOperator.Domain); + Assert.Equal("pw-secret", handler.LastOperator.Password); + Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal); + Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal); } private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler) @@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests /// Gets the last acknowledge alarm GUID. public Guid LastAckGuid { get; private set; } - /// Gets the last acknowledge operator name. - public string? LastAckOperatorName { get; private set; } + /// Gets the last acknowledge comment. + public string? LastComment { get; private set; } - /// Gets or sets the value returned by acknowledge. - public int AcknowledgeReturn { get; set; } + /// Gets the last acknowledge operator. + public AlarmAckOperator? LastOperator { get; private set; } - /// Gets or sets a value indicating whether acknowledge throws. - public bool AcknowledgeThrow { get; set; } + /// Gets or sets the result returned by both acknowledge methods. + public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); + + /// Gets or sets an exception both acknowledge methods throw. + public Exception? AcknowledgeException { get; set; } /// Gets or sets the query result snapshots. public IReadOnlyList QueryResult { get; set; } = @@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests } /// - public int Acknowledge( - Guid alarmGuid, string comment, string operatorUser, - string operatorNode, string operatorDomain, string operatorFullName) + public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator) { LastAckGuid = alarmGuid; - LastAckOperatorName = operatorUser; - if (AcknowledgeThrow) + LastComment = comment; + LastOperator = ackOperator; + if (AcknowledgeException is not null) { - throw new InvalidOperationException("simulated alarm-handler failure"); + throw AcknowledgeException; } - return AcknowledgeReturn; + + return AckResult; } /// - public int AcknowledgeByName( - string alarmName, string providerName, string groupName, - string comment, string operatorUser, string operatorNode, - string operatorDomain, string operatorFullName) + public AlarmAckResult AcknowledgeByName( + string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator) { LastAckByNameTuple = (alarmName, providerName, groupName); - LastAckOperatorName = operatorUser; - return AcknowledgeReturn; + LastComment = comment; + LastOperator = ackOperator; + if (AcknowledgeException is not null) + { + throw AcknowledgeException; + } + + return AckResult; } /// Gets the last acknowledge by name tuple. diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index c41807a..92c2d2c 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests Assert.False(handler.IsSubscribed); } - /// Verifies that acknowledge forwards to consumer with full operator identity when subscribed. + /// Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator. [Fact] - public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity() + public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator() { - FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 }; - AlarmCommandHandler handler = new AlarmCommandHandler( - new MxAccessEventQueue(), - () => consumer); + FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") }; + RecordingAckWriter writer = new RecordingAckWriter(); + AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer); handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); Guid g = Guid.NewGuid(); - int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F"); + AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" }; + AlarmAckResult result = handler.Acknowledge(g, "c", op); - Assert.Equal(0, rc); - Assert.Equal(g, consumer.LastAckGuid); - Assert.Equal("u", consumer.LastAckOperatorName); + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(g, consumer.LastResolvedGuid); + Assert.Equal("T.A.AckMsg", writer.LastItemAddress); + Assert.Equal("c", writer.LastComment); + Assert.Same(op, writer.LastOperator); + } + + /// Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty. + [Fact] + public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment() + { + FakeConsumer consumer = new FakeConsumer(); + RecordingAckWriter writer = new RecordingAckWriter(); + AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" }); + + Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName); + Assert.Equal(string.Empty, writer.LastComment); + } + + /// Verifies a null operator is rejected. + [Fact] + public void Acknowledge_NullOperator_Throws() + { + AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter()); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + Assert.Throws(() => handler.Acknowledge(Guid.NewGuid(), "c", null!)); + } + + /// + /// The ack writer is created once, on first acknowledgement, survives an + /// unsubscribe/resubscribe cycle, and is disposed with the handler. + /// + [Fact] + public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler() + { + RecordingAckWriter writer = new RecordingAckWriter(); + int created = 0; + AlarmCommandHandler handler = new AlarmCommandHandler( + new MxAccessEventQueue(), + () => new FakeConsumer(), + threadAffinityCheck: null, + comFactory: null, + standbyFactory: null, + ackWriterFactory: () => + { + created++; + return writer; + }); + + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + Assert.Equal(0, created); + + handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()); + handler.Unsubscribe(); + Assert.False(writer.Disposed); + + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator()); + Assert.Equal(1, created); + + handler.Dispose(); + Assert.True(writer.Disposed); + } + + /// + /// Without a COM factory or writer factory, subscribing still works and + /// the acknowledgement fails with a diagnosable InvalidOperationException. + /// + [Fact] + public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation() + { + AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer()); + handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); + + InvalidOperationException ex = Assert.Throws( + () => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator())); + Assert.Contains("IMxAccessComObjectFactory", ex.Message); } /// Verifies that acknowledge throws invalid operation when called before subscribe. @@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests () => new FakeConsumer()); Assert.Throws( - () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator())); } /// Verifies that query active returns mapped proto snapshots when consumer has alarms. @@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => consumer, - () => guardInvocations++); + () => guardInvocations++, + comFactory: null, + standbyFactory: null, + ackWriterFactory: () => new RecordingAckWriter()); // Subscribe is the first call — guard must run before the consumer // factory is invoked. We tally invocation counts after each call so @@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1"); Assert.Equal(1, guardInvocations); - handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F"); + handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" }); Assert.Equal(2, guardInvocations); - handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F"); + handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" }); Assert.Equal(3, guardInvocations); _ = handler.QueryActive(null, out _); @@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests // assert each independently with a fresh handler. Below we reuse // the same throwing handler for the not-subscribed-yet entries: Assert.Throws( - () => handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator())); Assert.Throws( - () => handler.AcknowledgeByName("", "", "", "", "", "", "", "")); + () => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator())); Assert.Throws(() => handler.QueryActive(null, out _)); Assert.Throws(() => handler.PollOnce()); Assert.Throws( @@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests [Fact] public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby() { - FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; - FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 }; - FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; + FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") }; + FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") }; + FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") }; int buildCount = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => primary, threadAffinityCheck: null, comFactory: null, - standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement); + standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement, + ackWriterFactory: () => new RecordingAckWriter()); handler.Subscribe(new SubscribeAlarmsCommand { @@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests Assert.Equal(2, buildCount); Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription); Assert.True(initialStandby.Disposed); - Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Equal("11.AckMsg", AckItemOf(handler)); } /// A replacement is rejected when no fallback-capable subscription is active. @@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests [Fact] public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode() { - FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 }; - FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; - FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 }; + FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") }; + FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") }; + FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") }; int buildCount = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => primary, null, null, - _ => ++buildCount == 1 ? oldStandby : failedReplacement); + _ => ++buildCount == 1 ? oldStandby : failedReplacement, + () => new RecordingAckWriter()); SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area", @@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests Assert.False(oldStandby.Disposed); Assert.True(failedReplacement.Disposed); - Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Equal("11.AckMsg", AckItemOf(handler)); } /// A successful replacement while failed over keeps subtag mode active and delegates to the new standby. [Fact] public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement() { - FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 }; - FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 }; - FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 }; + FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") }; + FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") }; + FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") }; int buildCount = 0; AlarmCommandHandler handler = new AlarmCommandHandler( new MxAccessEventQueue(), () => primary, null, null, - _ => ++buildCount == 1 ? oldStandby : replacement); + _ => ++buildCount == 1 ? oldStandby : replacement, + () => new RecordingAckWriter()); SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!Area", @@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") }); Assert.True(oldStandby.Disposed); - Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", "")); + Assert.Equal("33.AckMsg", AckItemOf(handler)); } /// @@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime()); } + private static AlarmCommandHandler NewHandlerWithWriter(Func consumerFactory, IAlarmAckWriter writer) => + new AlarmCommandHandler( + new MxAccessEventQueue(), + consumerFactory, + threadAffinityCheck: null, + comFactory: null, + standbyFactory: null, + ackWriterFactory: () => writer); + + /// Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic). + private static string AckItemOf(AlarmCommandHandler handler) => + handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic; + private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag) { return new MxAlarmSnapshotRecord @@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests /// Gets the last subscription request. public string? LastSubscription { get; private set; } - /// Gets the last acknowledged alarm GUID. - public Guid LastAckGuid { get; private set; } - /// Gets the last acknowledged operator name. - public string? LastAckOperatorName { get; private set; } - /// Gets or sets the return value for acknowledge operations. - public int AcknowledgeReturn { get; set; } + /// Gets or sets the ack target both resolve methods return. + public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg"); + /// Gets the GUID from the last ResolveAckTarget call. + public Guid LastResolvedGuid { get; private set; } + /// Gets the (name, provider, group) from the last ResolveAckTargetByName call. + public (string Name, string Provider, string Group)? LastResolvedName { get; private set; } /// Gets or sets the snapshot result to return. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); @@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests } /// - public int AcknowledgeByGuid( - Guid alarmGuid, string ackComment, string ackOperatorName, - string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { - LastAckGuid = alarmGuid; - LastAckOperatorName = ackOperatorName; - return AcknowledgeReturn; + LastResolvedGuid = alarmGuid; + return AckTarget; } /// - public int AcknowledgeByName( - string alarmName, string providerName, string groupName, - string ackComment, string ackOperatorName, string ackOperatorNode, - string ackOperatorDomain, string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { - LastAckByNameTuple = (alarmName, providerName, groupName); - LastAckOperatorName = ackOperatorName; - return AcknowledgeReturn; + LastResolvedName = (alarmName, providerName, groupName); + return AckTarget; } - /// Gets the last acknowledge-by-name parameters. - public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } - /// Gets or sets the truncation verdict this consumer reports with its snapshot. public bool SnapshotTruncated { get; set; } @@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests Disposed = true; } } + + private sealed class RecordingAckWriter : IAlarmAckWriter + { + public string? LastItemAddress { get; private set; } + public string? LastComment { get; private set; } + public AlarmAckOperator? LastOperator { get; private set; } + public bool Disposed { get; private set; } + + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) + { + LastItemAddress = itemAddress; + LastComment = comment; + LastOperator = ackOperator; + return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress); + } + + public void Dispose() => Disposed = true; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 677dc37..85ffd22 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -24,7 +24,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); consumer.RaiseTransition(new MxAlarmTransitionEvent { @@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc); consumer.RaiseTransition(new MxAlarmTransitionEvent @@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); consumer.RaiseTransition(new MxAlarmTransitionEvent { @@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); consumer.RaiseTransition(new MxAlarmTransitionEvent { @@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); } - /// Verifies that acknowledge calls are forwarded to the consumer with operator identity. + /// Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator. [Fact] - public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity() + public void Acknowledge_ResolvesTargetThenWritesWithOperator() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer(); - consumer.AcknowledgeReturn = 0; + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") }; + FakeAckWriter writer = new FakeAckWriter(); using AlarmDispatcher dispatcher = new AlarmDispatcher( - consumer, - new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); - + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge( - guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" }; - Assert.Equal(0, rc); - Assert.Equal(guid, consumer.LastAckGuid); - Assert.Equal("Acked", consumer.LastAckComment); - Assert.Equal("alice", consumer.LastAckOperatorName); - Assert.Equal("WS01", consumer.LastAckOperatorNode); - Assert.Equal("CORP", consumer.LastAckOperatorDomain); - Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName); + AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op); + + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(guid, consumer.LastResolvedGuid); + Assert.Equal("T.A.AckMsg", writer.LastItemAddress); + Assert.Equal("checked", writer.LastComment); + Assert.Same(op, writer.LastOperator); } - /// Verifies that a managed ack client takes GUID acks with full operator identity. + /// Verifies an unresolved target short-circuits to its failure without calling the writer. [Fact] - public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient() + public void Acknowledge_WhenTargetNotFound_DoesNotWrite() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 }; - FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 }; + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") }; + FakeAckWriter writer = new FakeAckWriter(); using AlarmDispatcher dispatcher = new AlarmDispatcher( - consumer, - new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId, - managed); - dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); - Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" }); - Assert.Equal(0, rc); - Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription); - Assert.Equal(guid, managed.LastAckGuid); - Assert.Equal("CORP", managed.LastAckOperatorDomain); - Assert.Equal("Alice Smith", managed.LastAckOperatorFullName); - Assert.Equal(Guid.Empty, consumer.LastAckGuid); + Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome); + Assert.Equal("gone", result.Diagnostic); + Assert.Null(writer.LastItemAddress); } - /// - /// Verifies that a failed managed-client lifecycle neither fails the alarm - /// subscription nor leaves acks routed to the broken client. - /// + /// Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item. [Fact] - public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck() + public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; - FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true }; + FakeAlarmConsumer consumer = new FakeAlarmConsumer + { + AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"), + }; + FakeAckWriter writer = new FakeAckWriter(); using AlarmDispatcher dispatcher = new AlarmDispatcher( - consumer, - new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId, - managed); + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); + AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" }; - dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); - Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + AlarmAckResult result = dispatcher.AcknowledgeByName( + "TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op); - Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription); - Assert.True(managed.Disposed); - Assert.Equal(0, rc); - Assert.Equal(guid, consumer.LastAckGuid); - Assert.Equal(Guid.Empty, managed.LastAckGuid); + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName); + Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress); + Assert.Equal("ack", writer.LastComment); + Assert.Same(op, writer.LastOperator); + } + + /// Verifies a NotAckable by-name target short-circuits without calling the writer. + [Fact] + public void AcknowledgeByName_WhenNotAckable_DoesNotWrite() + { + FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") }; + FakeAckWriter writer = new FakeAckWriter(); + using AlarmDispatcher dispatcher = new AlarmDispatcher( + consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer); + + AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator()); + + Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome); + Assert.Null(writer.LastItemAddress); } /// /// Verifies that while the failover consumer serves the subtag standby, - /// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client. + /// GUID acks resolve through the standby (its synthetic GUIDs). /// [Fact] - public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer() + public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby() { FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true }; - FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; + FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") }; FailoverAlarmConsumer failover = new FailoverAlarmConsumer( primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3)); - FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 }; + FakeAckWriter writer = new FakeAckWriter(); using AlarmDispatcher dispatcher = new AlarmDispatcher( failover, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, - managed); + writer); dispatcher.Subscribe(@"\\HOST\Galaxy!Area1"); Assert.Equal(AlarmProviderMode.Subtag, failover.Mode); Guid guid = Guid.NewGuid(); - int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith"); + dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" }); - Assert.Equal(0, rc); - Assert.Equal(guid, standby.LastAckGuid); - Assert.Equal(Guid.Empty, managed.LastAckGuid); + Assert.Equal(guid, standby.LastResolvedGuid); + Assert.Equal(Guid.Empty, primary.LastResolvedGuid); + Assert.Equal("Standby.AckMsg", writer.LastItemAddress); } - /// Verifies that disposing the dispatcher disposes the managed ack client. + /// Verifies the dispatcher does not dispose the writer: the handler owns it. [Fact] - public void Dispose_WithManagedAckClient_DisposesManagedClient() + public void Dispose_DoesNotDisposeAckWriter() { - FakeManagedAckClient managed = new FakeManagedAckClient(); + FakeAckWriter writer = new FakeAckWriter(); AlarmDispatcher dispatcher = new AlarmDispatcher( new FakeAlarmConsumer(), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, - managed); + writer); dispatcher.Dispose(); - Assert.True(managed.Disposed); + Assert.False(writer.Disposed); } - /// Verifies that acknowledge-by-name calls are forwarded to the consumer. + /// Verifies the constructor rejects a null ack writer. [Fact] - public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple() + public void Constructor_NullAckWriter_Throws() { - FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 }; - using AlarmDispatcher dispatcher = new AlarmDispatcher( - consumer, + Assert.Throws(() => new AlarmDispatcher( + new FakeAlarmConsumer(), new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); - - int rc = dispatcher.AcknowledgeByName( - alarmName: "TestMachine_001.TestAlarm001", - providerName: "Galaxy", - groupName: "TestArea", - ackComment: "ack", - ackOperatorName: "alice", - ackOperatorNode: "WS", - ackOperatorDomain: "CORP", - ackOperatorFullName: "Alice Smith"); - - Assert.Equal(0, rc); - Assert.NotNull(consumer.LastAckByNameTuple); - Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name); - Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider); - Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group); + SessionId, + null!)); } /// Verifies that consumer alarm records are mapped to proto snapshots. @@ -391,7 +376,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _); Assert.Equal(2, snapshots.Count); @@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _); Assert.Equal(2, snapshots.Count); @@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); @@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests using AlarmDispatcher dispatcher = new AlarmDispatcher( consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), - SessionId); + SessionId, + new FakeAckWriter()); IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated); @@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests FakeAlarmConsumer consumer = new FakeAlarmConsumer(); MxAccessEventQueue queue = new MxAccessEventQueue(); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter()); dispatcher.Dispose(); @@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests /// Gets the last subscription reference. public string? LastSubscription { get; private set; } - /// Gets the GUID from the last acknowledge call. - public Guid LastAckGuid { get; private set; } - /// Gets the comment from the last acknowledge call. - public string? LastAckComment { get; private set; } - /// Gets the operator name from the last acknowledge call. - public string? LastAckOperatorName { get; private set; } - /// Gets the operator node from the last acknowledge call. - public string? LastAckOperatorNode { get; private set; } - /// Gets the operator domain from the last acknowledge call. - public string? LastAckOperatorDomain { get; private set; } - /// Gets the operator full name from the last acknowledge call. - public string? LastAckOperatorFullName { get; private set; } - /// Gets or sets the return code for acknowledge operations. - public int AcknowledgeReturn { get; set; } + /// Gets or sets the ack target both resolve methods return. + public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg"); + /// Gets the GUID from the last ResolveAckTarget call. + public Guid LastResolvedGuid { get; private set; } + /// Gets the (name, provider, group) from the last ResolveAckTargetByName call. + public (string Name, string Provider, string Group)? LastResolvedName { get; private set; } /// Gets or sets the result collection for snapshot operations. public IReadOnlyList SnapshotResult { get; set; } = Array.Empty(); @@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests } /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + public AlarmAckTarget ResolveAckTarget(Guid alarmGuid) { - LastAckGuid = alarmGuid; - LastAckComment = ackComment; - LastAckOperatorName = ackOperatorName; - LastAckOperatorNode = ackOperatorNode; - LastAckOperatorDomain = ackOperatorDomain; - LastAckOperatorFullName = ackOperatorFullName; - return AcknowledgeReturn; + LastResolvedGuid = alarmGuid; + return AckTarget; } /// - public int AcknowledgeByName( - string alarmName, string providerName, string groupName, - string ackComment, string ackOperatorName, string ackOperatorNode, - string ackOperatorDomain, string ackOperatorFullName) + public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName) { - LastAckByNameTuple = (alarmName, providerName, groupName); - LastAckOperatorName = ackOperatorName; - return AcknowledgeReturn; + LastResolvedName = (alarmName, providerName, groupName); + return AckTarget; } - /// Gets the last acknowledge-by-name tuple (alarm name, provider, group). - public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; } - /// Gets or sets the truncation verdict this consumer reports with its snapshot. public bool SnapshotTruncated { get; set; } @@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests } } - private sealed class FakeManagedAckClient : IManagedAlarmAckClient + private sealed class FakeAckWriter : IAlarmAckWriter { - /// Gets the last subscription expression. - public string? LastSubscription { get; private set; } - /// Gets the GUID from the last acknowledge call. - public Guid LastAckGuid { get; private set; } - /// Gets the operator domain from the last acknowledge call. - public string? LastAckOperatorDomain { get; private set; } - /// Gets the operator full name from the last acknowledge call. - public string? LastAckOperatorFullName { get; private set; } - /// Gets or sets the return code for acknowledge operations. - public int AcknowledgeReturn { get; set; } - /// Gets or sets a value indicating whether throws. - public bool ThrowOnSubscribe { get; set; } - /// Gets a value indicating whether this instance has been disposed. + public string? LastItemAddress { get; private set; } + public string? LastComment { get; private set; } + public AlarmAckOperator? LastOperator { get; private set; } + public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); public bool Disposed { get; private set; } - /// - public void Subscribe(string subscription) + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) { - if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5."); - LastSubscription = subscription; + LastItemAddress = itemAddress; + LastComment = comment; + LastOperator = ackOperator; + return Result; } - /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName) - { - LastAckGuid = alarmGuid; - LastAckOperatorDomain = operatorDomain; - LastAckOperatorFullName = operatorFullName; - return AcknowledgeReturn; - } - - /// - public void Dispose() - { - Disposed = true; - } + public void Dispose() => Disposed = true; } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index 4659d70..07d378d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests } /// - public int Acknowledge(Guid alarmGuid, string comment, string operatorUser, - string operatorNode, string operatorDomain, string operatorFullName) - => 0; + public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator) + => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); /// - public int AcknowledgeByName(string alarmName, string providerName, string groupName, - string comment, string operatorUser, string operatorNode, - string operatorDomain, string operatorFullName) - => 0; + public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, + string comment, AlarmAckOperator ackOperator) + => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty); /// public IReadOnlyList QueryActive( diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs deleted file mode 100644 index f21a93f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using ZB.MOM.WW.MxGateway.Contracts.Proto; -using ZB.MOM.WW.MxGateway.Worker.MxAccess; -using Xunit; -using Xunit.Abstractions; - -namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes; - -/// -/// Live dev-rig smoke test for the MANAGED GUID-ack path — the -/// wired into -/// . Where AlarmsLiveSmokeTests acks by -/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's -/// modern aaAlarmManagedClient.AlarmClient.AlarmAckByGUID — the path -/// that carries operator domain + full name and is not E_NOTIMPL. -/// -/// Flow: subscribe a wnwrap reader + a managed ack client to -/// \\<machine>\Galaxy!DEV; wait for a Raise; pull a real -/// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via -/// the dispatcher (which routes to the managed client); then assert the ack -/// registers as a subsequent -/// transition (and that the active snapshot shows the alarm acked). -/// -/// This is the runtime confirmation the static work could not close: that an -/// authenticated GUID ack actually flips state. rc=0 alone is "accepted", -/// not "applied" — the Acknowledge transition is the real assertion. For a -/// SECURED alarm the ack must run under an operator with Operate permission; -/// run the dev-rig flip script and, if the test UDA is secured, an -/// authenticated operator session (see docs/AlarmProbeFindings.md). -/// -/// Skip-gated; flip Skip=null on the dev rig with AVEVA + -/// aaAlarmManagedClient.dll present and the flip script running. -/// -public sealed class AlarmManagedGuidAckLiveSmokeTests -{ - private static readonly string SubscriptionExpression = - $@"\\{Environment.MachineName}\Galaxy!DEV"; - private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20); - private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30); - private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30); - - private const string SessionId = "alarms-managed-guid-ack-smoke"; - - private readonly ITestOutputHelper output; - private readonly Stopwatch elapsed = Stopwatch.StartNew(); - private readonly ConcurrentQueue log = new ConcurrentQueue(); - - /// Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class. - /// Test output helper for logging. - public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output) - { - this.output = output; - } - - /// Acks a live alarm by GUID through the managed client and verifies the acked transition. - [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")] - public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge() - { - Exception? threadException = null; - var done = new ManualResetEventSlim(false); - var thread = new Thread(() => - { - try { RunSmoke(); } - catch (Exception ex) { threadException = ex; } - finally { done.Set(); } - }); - thread.IsBackground = false; - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - done.Wait(); - thread.Join(); - - output.WriteLine($"Captured {log.Count} log line(s):"); - while (log.TryDequeue(out string? line)) output.WriteLine(line); - - if (threadException != null) throw threadException; - } - - private void RunSmoke() - { - Log($"Subscription expression: {SubscriptionExpression}"); - - MxAccessEventQueue queue = new MxAccessEventQueue(); - WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer( - new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), - maxAlarmsPerFetch: 1024); - MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - - // Construct the managed ack client directly (production selects it via - // AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1). - using IManagedAlarmAckClient managedAck = - new AaManagedAlarmAckConsumer(SessionId + ".ack"); - using AlarmDispatcher dispatcher = - new AlarmDispatcher(consumer, sink, SessionId, managedAck); - - Log("Constructed reader + managed ack client + dispatcher."); - dispatcher.Subscribe(SubscriptionExpression); - Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA..."); - - // 1. Wait for a Raise so there is an active, unacked alarm to ack. - OnAlarmTransitionEvent? raiseBody = null; - DateTime raiseDeadline = DateTime.UtcNow + RaiseWait; - while (DateTime.UtcNow < raiseDeadline && raiseBody is null) - { - WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer); - if (evt is null) break; - OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition; - Log("Transition: " + DescribeTransition(body)); - Assert.Equal(SessionId, evt.Event.SessionId); - if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body; - } - Assert.NotNull(raiseBody); - - // 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode). - // Skip degraded/subtag records — their GUID is synthetic and not - // valid for AlarmAckByGUID. - var records = consumer.SnapshotActiveAlarms(out _); - Log($"Snapshot records: {records.Count}"); - foreach (var r in records) - Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}"); - - MxAlarmSnapshotRecord? target = records.FirstOrDefault(r => - !r.Degraded && - r.AlarmGuid != Guid.Empty && - r.State == MxAlarmStateKind.UnackAlm); - Assert.NotNull(target); - Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}"); - - // 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with - // the current Windows identity (domain + full name are carried - // natively on this path, unlike the wnwrap 6-arg AckByName). - string user = Environment.UserName; - string node = Environment.MachineName; - string domain = Environment.UserDomainName ?? string.Empty; - Log($"Ack identity user='{user}' node='{node}' domain='{domain}'"); - - int rc = dispatcher.Acknowledge( - alarmGuid: target.AlarmGuid, - ackComment: "managed-guid-ack smoke", - ackOperatorName: user, - ackOperatorNode: node, - ackOperatorDomain: domain, - ackOperatorFullName: user); - Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}"); - Assert.Equal(0, rc); - - // 4. The ack must APPLY, not merely be accepted: wait for an - // Acknowledge transition on the same alarm, or the snapshot showing - // it acked. rc=0 with neither is the inert-ack failure the probe saw. - bool acked = false; - DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait; - while (DateTime.UtcNow < confirmDeadline && !acked) - { - WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer); - if (evt is not null) - { - OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition; - Log("Post-ack transition: " + DescribeTransition(body)); - if (body.TransitionKind == AlarmTransitionKind.Acknowledge) - { - acked = true; - break; - } - } - - // Also accept the active snapshot flipping to an acked state for the - // target GUID, in case the ack delta was folded without a discrete - // Acknowledge transition. - var now = consumer.SnapshotActiveAlarms(out _); - if (now.Any(r => r.AlarmGuid == target.AlarmGuid && - (r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName)))) - { - Log("Snapshot shows target GUID acked (AckAlm / operator populated)."); - acked = true; - } - } - - Assert.True( - acked, - "Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " + - "within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " + - "most likely the alarm's MxSecurityOperate classification requires an authenticated operator)."); - } - - private WorkerEvent? WaitForTransition( - MxAccessEventQueue queue, - TimeSpan timeout, - string label, - WnWrapAlarmConsumer consumer) - { - DateTime deadline = DateTime.UtcNow + timeout; - int pollCount = 0; - while (DateTime.UtcNow < deadline) - { - try - { - consumer.PollOnce(); - pollCount++; - } - catch (Exception ex) - { - Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}"); - throw; - } - if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null) - { - if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt; - Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}."); - } - Thread.Sleep(500); - } - Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount})."); - return null; - } - - private static string DescribeTransition(OnAlarmTransitionEvent body) - { - return string.Format( - "kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}", - body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment, - body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue); - } - - private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); -} diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs index 181add7..d8c6a3d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs @@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests new WNWRAPCONSUMERLib.wwAlarmConsumerClass(), maxAlarmsPerFetch: 1024); MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()); - using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId); + using AlarmAckWriter ackWriter = new AlarmAckWriter( + new MxAccessComObjectFactory(), + () => new ZB.MOM.WW.MxGateway.Worker.Sta.StaMessagePump().PumpPendingMessages(), + TimeSpan.FromSeconds(5), + AlarmAckWriter.DefaultClientName); + using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, ackWriter); Log("Constructed consumer + sink + dispatcher."); dispatcher.Subscribe(SubscriptionExpression); @@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests $"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag."); Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'"); - // Try the ack with real Windows identity. AVEVA's AlarmAckByName - // may reject synthetic operator strings; using the current process - // identity gives the alarm-history a recognizable principal. - string realUser = Environment.UserName; - string realNode = Environment.MachineName; - string realDomain = Environment.UserDomainName ?? string.Empty; - Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'"); + // The ack authenticates a Galaxy operator and writes .AckMsg. + // Operator credentials come from the environment (never logged); + // the user defaults to the current process identity. + AlarmAckOperator ackOperator = new AlarmAckOperator + { + UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName, + Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty, + }; + Log($"Ack identity: {ackOperator}"); - int rc = dispatcher.AcknowledgeByName( - alarmName: alarmName, - providerName: provider, - groupName: group, - ackComment: "alarms-live-smoke ack", - ackOperatorName: realUser, - ackOperatorNode: realNode, - ackOperatorDomain: realDomain, - ackOperatorFullName: realUser); - Log($"AcknowledgeByName(real identity) -> rc={rc}"); + AlarmAckResult result = dispatcher.AcknowledgeByName( + alarmName, provider, group, "alarms-live-smoke ack", ackOperator); + Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'"); - Assert.Equal(0, rc); + Assert.True( + result.Outcome == AlarmAckOutcome.Applied || result.Outcome == AlarmAckOutcome.Unconfirmed, + $"Ack outcome {result.Outcome}: {result.Diagnostic}"); // 4. Wait for the post-ack transition. With the alarm flipping every // 10s and the consumer polling every 500ms, the next state diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs index 44da620..c9afa8d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs @@ -76,7 +76,7 @@ public sealed class WorkerPipeSession options, () => Process.GetCurrentProcess().Id, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)), + () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), logger, stream) { @@ -98,7 +98,7 @@ public sealed class WorkerPipeSession options, processIdProvider, new WorkerPipeSessionOptions(), - () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)), + () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)), logger: null) { } @@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession // parameterless CompleteStartupHandshakeAsync is used without a // prior factory call. _runtimeSession ??= new MxAccessStaSession( - (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)); + (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)); IWorkerRuntimeSession session = _runtimeSession; try { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs deleted file mode 100644 index 8c68f9f..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using WNWRAPCONSUMERLib; - -namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; - -/// -/// Acknowledge path over AVEVA's modern managed alarm client, -/// aaAlarmManagedClient.AlarmClient (the C++/CLI assembly the AVEVA -/// Alarm Control itself uses: AlarmClient → CwwAlarmConsumer → -/// alarmlst.dll → alarmmgr.exe). Preferred over a hand-rolled native -/// WNAL consumer because AlarmAckByGUID performs the -/// register → match-by-GUID → submit sequence internally — the record -/// layout and selection semantics that are provider-defined (and therefore -/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's -/// own code. See for why the native -/// acquisition path is deliberately unfinished. -/// -/// The AlarmClient members are famorasm (protected-internal) -/// and mxaccessgw is not an InternalsVisibleTo friend, so everything -/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is -/// permitted. Method shapes are confirmed from the assembly metadata: -/// .ctor() (internal) -/// int InitializeConsumer(string appName) -/// int RegisterConsumer(int hWnd, string product, string app, string version, bool) -/// int Subscribe(string expr, short fromPri, short toPri, -/// localQueryType, localSortFlags, -/// localAlarmFilterState mask, localAlarmFilterState spec) -/// int AlarmAckByGUID(System.Guid guid, string comment, string oper, -/// string node, string domain, string fullName) -/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose() -/// -/// THREADING: the underlying consumer is apartment-affine (same as the -/// wnwrap COM path). Construct and call this only on the worker STA -/// (marshal via StaRuntime.InvokeAsync). -/// -/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE. -/// -/// OPEN (confirm at runtime, flagged inline): the trailing bool of -/// RegisterConsumer (passed false here); whether -/// Subscribe is actually required before an ack by GUID; and the -/// native status returned on a real secured ack. -/// -public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient -{ - /// Default install location of the managed alarm client (eac.dll folder). - public const string DefaultAssemblyPath = - @"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll"; - - /// - /// Env var that opts the worker into the managed ack path. Unset/anything - /// other than "1" keeps the existing wnwrap ack behavior. - /// - public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK"; - - private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack"; - - /// - /// Production factory for the seam: - /// returns a managed ack client only when == "1", - /// otherwise (ack stays on the wnwrap path). Never - /// throws — a construction failure (missing DLL, etc.) degrades to the - /// wnwrap path rather than breaking the alarm subscription. - /// - public static IManagedAlarmAckClient? EnvGatedFactory() - { - if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal)) - return null; - try - { - return new AaManagedAlarmAckConsumer(AckApplicationName); - } - catch - { - // Opt-in enhancement only: if the managed client can't load, fall - // back to the wnwrap ack path rather than failing alarm reads. - return null; - } - } - - private const string TypeName = "aaAlarmManagedClient.AlarmClient"; - private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - private readonly object syncRoot = new(); - private readonly object client; - private readonly MethodInfo initializeConsumer; - private readonly MethodInfo registerConsumer; - private readonly MethodInfo subscribe; - private readonly MethodInfo alarmAckByGuid; - private readonly MethodInfo? deregisterConsumer; - private readonly MethodInfo? uninitializeConsumer; - - private bool subscribed; - private bool disposed; - - /// Consumer application name (e.g. "MxGateway.ack"). - /// Override for . - public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null) - { - if (string.IsNullOrWhiteSpace(applicationName)) - throw new ArgumentException("Application name is required.", nameof(applicationName)); - - string path = assemblyPath ?? DefaultAssemblyPath; - if (!File.Exists(path)) - throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path); - - Assembly asm = Assembly.LoadFrom(path); - Type type = asm.GetType(TypeName, throwOnError: true) - ?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}."); - - initializeConsumer = Resolve(type, "InitializeConsumer"); - registerConsumer = Resolve(type, "RegisterConsumer"); - subscribe = Resolve(type, "Subscribe"); - alarmAckByGuid = Resolve(type, "AlarmAckByGUID"); - deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null); - uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null); - - client = Activator.CreateInstance(type, nonPublic: true) - ?? throw new InvalidOperationException($"Could not construct '{TypeName}'."); - - ApplicationName = applicationName; - } - - /// The consumer application name this instance registered under. - public string ApplicationName { get; } - - private static MethodInfo Resolve(Type type, string name) => - type.GetMethod(name, Inst) - ?? throw new MissingMethodException(TypeName, name); - - /// - /// Run the Initialize → Register → Subscribe lifecycle once, mirroring the - /// proven wnwrap argument values. is the - /// alarm query root, e.g. \\NODE\Galaxy!Area. Throws if any native - /// status is non-zero. - /// - public void Subscribe(string subscription) - { - if (subscription is null) throw new ArgumentNullException(nameof(subscription)); - lock (syncRoot) - { - if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer)); - if (subscribed) throw new InvalidOperationException("Subscribe was already called."); - - int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!; - if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}."); - - // RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model. - // The trailing bool is unconfirmed; false = client/non-modal is the safe default. - int reg = (int)registerConsumer.Invoke(client, new object[] - { - 0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false, - })!; - if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}."); - - int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!; - if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}."); - - subscribed = true; - } - } - - /// - /// Acknowledge a single alarm by GUID with full operator identity. Returns - /// AVEVA's native status (0 = success). A zero return means accepted, not - /// necessarily applied — verify the alarm's acked state / Historian row - /// (see docs/AlarmProbeFindings.md). - /// - public int AcknowledgeByGuid( - Guid alarmGuid, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName) - { - lock (syncRoot) - { - if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer)); - if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging."); - - return (int)alarmAckByGuid.Invoke(client, new object[] - { - alarmGuid, - comment ?? string.Empty, - operatorName ?? string.Empty, - operatorNode ?? string.Empty, - operatorDomain ?? string.Empty, - operatorFullName ?? string.Empty, - })!; - } - } - - /// - /// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into - /// aaAlarmManagedClient's own enum parameter types (localQueryType / - /// localSortFlags / localAlarmFilterState) via . - /// - private object[] BuildSubscribeArgs(string subscription) - { - ParameterInfo[] p = subscribe.GetParameters(); - if (p.Length != 7) - throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7."); - - return new object[] - { - subscription, - (short)1, // wFromPri - (short)999, // wToPri - Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary), - Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst), - Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow), - Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow), - }; - } - - /// - public void Dispose() - { - lock (syncRoot) - { - if (disposed) return; - disposed = true; - - try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty()); } catch { /* best effort */ } - try { uninitializeConsumer?.Invoke(client, Array.Empty()); } catch { /* best effort */ } - (client as IDisposable)?.Dispose(); - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index 3b10631..3648511 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -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, IMxAccessAlarmConsumer>? standbyFactory; - private readonly Func? managedAckFactory; + private readonly Func? 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? providerModeChangedHandler; private string subscribeSessionId = string.Empty; @@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler /// . Tests inject a fake so they need no /// live COM factory. /// - /// - /// Optional per-subscription factory for a managed ack client - /// (). When it yields a non-null - /// client, GUID-keyed acknowledges route through AVEVA's modern - /// AlarmClient.AlarmAckByGUID instead of the wnwrap COM path. - /// Defaults to (wnwrap ack). Production supplies - /// , which is a - /// no-op unless MXGATEWAY_USE_MANAGED_ALARM_ACK=1. + /// + /// Optional seam that builds the used for + /// every acknowledgement. Defaults to an over + /// that registers its own MXAccess server + /// (client name from ). + /// 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. /// public AlarmCommandHandler( MxAccessEventQueue eventQueue, @@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler Action? threadAffinityCheck, IMxAccessComObjectFactory? comFactory, Func, IMxAccessAlarmConsumer>? standbyFactory, - Func? managedAckFactory = null) + Func? 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); } /// Gets a value indicating whether the handler is subscribed. @@ -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 } /// - 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); } /// - 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); } /// @@ -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; + } + } + + /// + /// The writer handed to each . 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. + /// + 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 */ } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index 722db2e..6923565 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// worker's IPC layer issues SubscribeAlarmsCommand / /// AcknowledgeAlarmCommand / QueryActiveAlarmsCommand /// through , which owns one -/// dispatcher per session. +/// dispatcher per session. Acknowledgement resolves the alarm to its +/// MXAccess ack item through the consumer and hands the write to the +/// , which authenticates as the operator. /// /// /// Threading: owns no internal @@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable private readonly MxAccessAlarmEventSink sink; private readonly string sessionId; private readonly EventHandler handler; - private IManagedAlarmAckClient? managedAck; + private readonly IAlarmAckWriter ackWriter; private bool disposed; - /// Initializes a new alarm dispatcher for the given consumer, sink, and session ID. + /// Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer. /// The alarm consumer. /// The alarm event sink. /// The session identifier. - /// - /// Optional managed ack client. When non-null, GUID-keyed acknowledges - /// route to it (AVEVA's modern AlarmClient.AlarmAckByGUID) instead - /// of the wnwrap consumer while the alarmmgr provider is active; null - /// keeps the existing wnwrap ack path. Subtag-mode acks always stay on - /// the consumer because their GUIDs are synthetic. + /// + /// Writes the resolved ack item as the authenticated operator. Owned by + /// the caller (), which disposes it; the + /// dispatcher never does, so the writer's registered MXAccess server + /// outlives an unsubscribe/resubscribe cycle. /// public AlarmDispatcher( IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId, - IManagedAlarmAckClient? managedAck = null) + IAlarmAckWriter ackWriter) { this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); this.sink = sink ?? throw new ArgumentNullException(nameof(sink)); this.sessionId = sessionId ?? string.Empty; - this.managedAck = managedAck; + this.ackWriter = ackWriter ?? throw new ArgumentNullException(nameof(ackWriter)); // Sink.Attach is the seam that propagates the session id onto the // proto SessionId field of every emitted MxEvent. Pass the consumer // as the "associated COM object" — sink ignores the object reference @@ -80,113 +81,37 @@ public sealed class AlarmDispatcher : IDisposable { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); consumer.Subscribe(subscription); - // The managed ack client maintains its own registered/subscribed - // consumer; run its lifecycle against the same expression so a later - // AlarmAckByGUID has a live consumer to act through. It is an opt-in - // enhancement, so a failed lifecycle drops back to the wnwrap ack path - // rather than failing the alarm subscription. - if (managedAck is not null) - { - try - { - managedAck.Subscribe(subscription); - } - catch - { - try { managedAck.Dispose(); } catch { /* swallow */ } - managedAck = null; - } - } } - /// - /// True when the consumer is currently serving alarms from the subtag - /// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr. - /// - private bool IsSubtagActive() => - consumer is SubtagAlarmConsumer || - (consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag); - - /// - /// Forward an AcknowledgeAlarm request to the underlying - /// consumer's AlarmAckByGUID. Returns the AVEVA-native - /// status code (0 = success). - /// + /// Acknowledges the alarm with the given GUID by writing its ack item as the operator. /// The alarm GUID. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - public int Acknowledge( - Guid alarmGuid, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); - if (managedAck is not null && !IsSubtagActive()) - { - // Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries - // operator domain + full name and is not E_NOTIMPL like the wnwrap - // COM AlarmAckByGUID. Skipped in subtag mode: those GUIDs are - // synthetic and only the subtag consumer can resolve them (it acks - // via an AckMsg write). - return managedAck.AcknowledgeByGuid( - alarmGuid, - ackComment, - ackOperatorName, - ackOperatorNode, - ackOperatorDomain, - ackOperatorFullName); - } - - return consumer.AcknowledgeByGuid( - alarmGuid, - ackComment, - ackOperatorName, - ackOperatorNode, - ackOperatorDomain, - ackOperatorFullName); + AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid); + return target.Found + ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator) + : AlarmAckResult.FromTarget(target); } - /// - /// Acknowledge an alarm by its (name, provider, group) tuple. - /// Routes to the consumer's AcknowledgeByName path which - /// maps to wwAlarmConsumerClass.AlarmAckByName. - /// - /// The alarm name. - /// The provider name. - /// The group name. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - public int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string ackComment, - string ackOperatorName, - string ackOperatorNode, - string ackOperatorDomain, - string ackOperatorFullName) + /// Acknowledges the alarm identified by name/provider/group as the operator. + /// The tag/alarm name. + /// The provider. + /// The group/area. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + public AlarmAckResult AcknowledgeByName( + string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); - return consumer.AcknowledgeByName( - alarmName, - providerName, - groupName, - ackComment, - ackOperatorName, - ackOperatorNode, - ackOperatorDomain, - ackOperatorFullName); + AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName); + return target.Found + ? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator) + : AlarmAckResult.FromTarget(target); } /// @@ -327,6 +252,5 @@ public sealed class AlarmDispatcher : IDisposable try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ } try { sink.Detach(); } catch { /* swallow */ } try { consumer.Dispose(); } catch { /* swallow */ } - try { managedAck?.Dispose(); } catch { /* swallow */ } } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index 52ecfe0..28c29ee 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable /// Tear down the active subscription. No-op if not subscribed. void Unsubscribe(); - /// Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success). + /// Acknowledges an alarm by GUID as . /// The alarm GUID. - /// The acknowledgment comment. - /// The operator user name. - /// The operator node name. - /// The operator domain name. - /// The operator full name. - /// AVEVA's native alarm status code (0 = success). - int Acknowledge( - Guid alarmGuid, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName); + /// The acknowledgement comment written to the alarm's ack item. + /// The operator to authenticate as (credential-sensitive). + /// The acknowledgement outcome. + AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator); - /// - /// Acknowledge a single alarm by (name, provider, group) — used when - /// the caller has the human-readable reference but not the GUID. - /// - /// The alarm name. + /// Acknowledges an alarm by name/provider/group as . + /// The alarm (tag) name. /// The provider name. /// The group name. - /// The acknowledgment comment. - /// The operator user name. - /// The operator node name. - /// The operator domain name. - /// The operator full name. - /// AVEVA's native alarm status code (0 = success). - int AcknowledgeByName( - string alarmName, - string providerName, - string groupName, - string comment, - string operatorUser, - string operatorNode, - string operatorDomain, - string operatorFullName); + /// The acknowledgement comment written to the alarm's ack item. + /// The operator to authenticate as (credential-sensitive). + /// The acknowledgement outcome. + AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator); /// /// Snapshot the currently-active alarm set, optionally scoped to a diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs deleted file mode 100644 index ffe0258..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IManagedAlarmAckClient.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; - -namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; - -/// -/// Optional ack-only seam over AVEVA's modern managed alarm client -/// (aaAlarmManagedClient.AlarmClient). When supplied to an -/// , GUID-keyed acknowledges route here -/// instead of the wnwrap COM consumer's AlarmAckByGUID (which is -/// E_NOTIMPL on current builds) — carrying full operator identity -/// (domain + full name) that the wnwrap 6-arg AlarmAckByName drops. -/// The production implementation is . -/// -public interface IManagedAlarmAckClient : IDisposable -{ - /// - /// Run the client's Initialize → Register → Subscribe lifecycle against - /// the same subscription expression the read consumer uses - /// (\\<node>\Galaxy!<area>). Must be called once, - /// on the worker STA, before . - /// - /// The subscription expression. - void Subscribe(string subscription); - - /// - /// Acknowledge a single alarm by GUID with full operator identity. - /// Returns AVEVA's native status (0 = success; 0 means accepted, not - /// necessarily applied — verify the alarm's acked state / Historian row). - /// - /// The alarm GUID. - /// The acknowledgment comment. - /// The operator name. - /// The operator node. - /// The operator domain. - /// The operator full name. - /// The AVEVA-native status code. - int AcknowledgeByGuid( - Guid alarmGuid, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName); -} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 4442805..a9cfd0b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Worker.Conversion; using ZB.MOM.WW.MxGateway.Worker.Sta; @@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor $"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'."); } + // operator_password is a raw credential: it goes into AlarmAckOperator for + // MXAccess AuthenticateUser and nowhere else. Never format the payload + // (its generated ToString renders every field) or the exception text. try { - int rc = alarmCommandHandler.Acknowledge( + AlarmAckResult result = alarmCommandHandler.Acknowledge( alarmGuid, payload.Comment, - payload.OperatorUser, - payload.OperatorNode, - payload.OperatorDomain, - payload.OperatorFullName); - MxCommandReply reply = CreateOkReply(command); - reply.Hresult = rc; - reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload - { - NativeStatus = rc, - }; - if (rc != 0) - { - reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}."; - } - return reply; + new AlarmAckOperator + { + UserName = payload.OperatorUser, + Domain = payload.OperatorDomain, + Password = payload.OperatorPassword, + }); + return CreateAcknowledgeReply(command, result); } catch (Exception ex) { - return CreateAlarmFailureReply(command, ex); + return CreateAcknowledgeFailureReply(command, ex); } } @@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required."); } + // See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator. try { - int rc = alarmCommandHandler.AcknowledgeByName( + AlarmAckResult result = alarmCommandHandler.AcknowledgeByName( payload.AlarmName, payload.ProviderName, payload.GroupName, payload.Comment, - payload.OperatorUser, - payload.OperatorNode, - payload.OperatorDomain, - payload.OperatorFullName); - MxCommandReply reply = CreateOkReply(command); - reply.Hresult = rc; - reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload - { - NativeStatus = rc, - }; - if (rc != 0) - { - reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}."; - } - return reply; + new AlarmAckOperator + { + UserName = payload.OperatorUser, + Domain = payload.OperatorDomain, + Password = payload.OperatorPassword, + }); + return CreateAcknowledgeReply(command, result); } catch (Exception ex) { - return CreateAlarmFailureReply(command, ex); + return CreateAcknowledgeFailureReply(command, ex); } } + private static MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result) + { + MxCommandReply reply = CreateOkReply(command); + reply.Hresult = result.NativeStatus; + reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus }; + reply.Statuses.Add(result.Statuses); + reply.DiagnosticMessage = result.Diagnostic; + switch (result.Outcome) + { + case AlarmAckOutcome.AlarmNotFound: + case AlarmAckOutcome.NotAckable: + reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic }; + break; + case AlarmAckOutcome.AuthenticationFailed: + case AlarmAckOutcome.WriteRejected: + reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic }; + break; + } + + return reply; + } + + /// + /// Maps an exception escaping an acknowledgement (e.g. the ack writer's + /// MXAccess Register failing, or no active subscription) to an + /// reply. Only the + /// exception type and HRESULT are reported: exception text on this path + /// is not under our control and must not be able to echo operator input. + /// + private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception) + { + string diagnostic = string.Format( + CultureInfo.InvariantCulture, + "Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).", + exception.GetType().FullName, + exception.HResult); + return new MxCommandReply + { + SessionId = command.SessionId, + CorrelationId = command.CorrelationId, + Kind = command.Kind, + Hresult = exception.HResult, + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.MxaccessFailure, + Message = diagnostic, + }, + DiagnosticMessage = diagnostic, + AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult }, + }; + } + private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command) { if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs deleted file mode 100644 index b86daa9..0000000 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnalNativeAckConsumer.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using System.Text; - -namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; - -/// -/// Native alarm-acknowledge path over alarmlst.dll's public WNAL* -/// C exports (the same stack the AVEVA Alarm Control uses: -/// aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe). -/// -/// Why this exists alongside : the legacy -/// WNWRAPCONSUMERLib COM surface cannot carry a full/secured ack — -/// AlarmAckByGUID is E_NOTIMPL, the 8-arg AlarmAckByName -/// is a vendor stub (-55), and neither exposes the signed-ack password. The -/// native request record built by has six text -/// fields — node, operator, comment, password, domain, full name — so -/// this path can do a fully-attributed (and signed) ack. -/// -/// THREADING: these calls are not inherently STA-bound the way the COM -/// consumer is, but keep all of them on the worker STA for ordering/parity -/// with the rest of the alarm surface (marshal via StaRuntime.InvokeAsync). -/// -/// x86 ONLY: alarmlst.dll is 32-bit; this compiles into the x86 worker. -/// -/// STATUS: the ack-SUBMIT contract (, -/// , ) and the -/// request-record layout are confirmed by disassembly (see -/// docs/alarm-ack-findings.md in the reverse-engineering workspace). The -/// list-ACQUISITION side (obtaining hAlarmList + alarm handles) is -/// deliberately NOT implemented — see the note at the end of this class. -/// Nothing in the worker calls this type yet; it is kept for when a real -/// list handle can be supplied by a component that owns acquisition. -/// -public sealed class WnalNativeAckConsumer -{ - private const string AlarmLst = "alarmlst.dll"; - - /// Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else. - private const ushort AlmAckVersion = 0x104; - - /// Byte offset where WNALalarmReqSetText begins the string-offset table + heap. - private const int StringHeapBase = 0x42; - - /// Sentinel the exports reject as an invalid list handle. - public const int InvalidAlarmList = -1; - - // --------------------------------------------------------------------- - // CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI. - // --------------------------------------------------------------------- - - /// - /// Pack operator identity + comment into an existing request record. - /// Arg order is confirmed from the debug format string. Per-field rule: - /// pass null to KEEP the record's existing value, "" to CLEAR it, - /// a string to SET it. Strings are single-byte ASCII (strcpy_s). - /// Return: positive = bytes used; NEGATIVE = record too small, magnitude - /// is the required recSize; 0 = was null. - /// - [DllImport(AlarmLst, EntryPoint = "WNALalarmReqSetText", - CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, ExactSpelling = true)] - private static extern int WnalReqSetText( - IntPtr lpAlReq, - [MarshalAs(UnmanagedType.LPStr)] string? nodeName, - [MarshalAs(UnmanagedType.LPStr)] string? oper, - [MarshalAs(UnmanagedType.LPStr)] string? comment, - [MarshalAs(UnmanagedType.LPStr)] string? passWd, - [MarshalAs(UnmanagedType.LPStr)] string? domainName, - [MarshalAs(UnmanagedType.LPStr)] string? fullName); - - /// - /// Submit a user request (an ack) over the alarms SELECTED in - /// . a2 is reserved (0); a3 is a capacity - /// hint (the native client passes the grid row count); a4 is scope - /// (0 = all, 1 = selected). Returns 0 on success (provider status), - /// non-zero on error. - /// - [DllImport(AlarmLst, EntryPoint = "WNALUserRequest", - CallingConvention = CallingConvention.StdCall, ExactSpelling = true)] - private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck); - - /// - /// Submit a user request (an ack) against an EXPLICIT array of - /// 4-byte alarm handles — no selection state - /// required. mirrors the scope/flag scalar. - /// Returns 0 on success. - /// - [DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx", - CallingConvention = CallingConvention.StdCall, ExactSpelling = true)] - private static extern int WnalUserRequestEx( - int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck); - - /// Acknowledge-scope values for the a4 argument. - private enum AckScope - { - All = 0, - Selected = 1, - } - - /// - /// Build a request record and submit an ack for the given alarm - /// handles via . Caller supplies a live - /// and the handle(s) obtained from the - /// list's records (acquisition is not implemented — see the class note). - /// Returns the native status (0 == success). - /// - /// A registered, subscribed alarm-list handle. - /// The 4-byte alarm handles to acknowledge. - /// Ack comment. - /// Operator / user name. - /// Operator node name. - /// Operator domain (carried natively, unlike the COM path). - /// Operator full name (carried natively, unlike the COM path). - /// Operator password for a signed/secured ack, or null. - public int AcknowledgeByHandles( - int hAlarmList, - int[] alarmHandles, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName, - string? password) - { - if (hAlarmList == InvalidAlarmList) - throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList)); - if (alarmHandles is null || alarmHandles.Length == 0) - throw new ArgumentException("At least one alarm handle is required.", nameof(alarmHandles)); - - IntPtr record = IntPtr.Zero; - IntPtr handles = IntPtr.Zero; - try - { - record = BuildAckRecord( - nodeName: operatorNode, - oper: operatorName, - comment: comment, - passWd: password, - domainName: operatorDomain, - fullName: operatorFullName); - - handles = Marshal.AllocHGlobal(alarmHandles.Length * sizeof(int)); - for (int i = 0; i < alarmHandles.Length; i++) - Marshal.WriteInt32(handles, i * sizeof(int), alarmHandles[i]); - - return WnalUserRequestEx( - hAlarmList, - handles, - alarmHandles.Length, - (uint)AckScope.Selected, - record); - } - finally - { - if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); - if (record != IntPtr.Zero) Marshal.FreeHGlobal(record); - } - } - - /// - /// Acknowledge whatever is currently SELECTED in the list (a4 = Selected) - /// via . Use when selection has already been - /// established on the list rather than passing explicit handles. - /// - public int AcknowledgeSelected( - int hAlarmList, - uint rowCountHint, - string comment, - string operatorName, - string operatorNode, - string operatorDomain, - string operatorFullName, - string? password) - { - if (hAlarmList == InvalidAlarmList) - throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList)); - - IntPtr record = IntPtr.Zero; - try - { - record = BuildAckRecord(operatorNode, operatorName, comment, password, operatorDomain, operatorFullName); - return WnalUserRequest(hAlarmList, a2: 0, a3: rowCountHint, a4: (int)AckScope.Selected, record); - } - finally - { - if (record != IntPtr.Zero) Marshal.FreeHGlobal(record); - } - } - - /// - /// Allocate a zeroed request record (header + room for the string heap), - /// stamp recSize + version, then let pack the - /// operator/comment text. Returns an unmanaged buffer the caller must free. - /// - private static IntPtr BuildAckRecord( - string nodeName, string oper, string comment, string? passWd, string domainName, string fullName) - { - // recSize must be >= 0x42 + sum(strlen+1 of each field). Size generously; - // SetText returns a negative "needed size" if this is too small, which we - // honor by growing once. - int capacity = EstimateCapacity(nodeName, oper, comment, passWd, domainName, fullName); - - for (int attempt = 0; attempt < 2; attempt++) - { - IntPtr buf = Marshal.AllocHGlobal(capacity); - try - { - // Zero the whole record (fixed header 0x04..0x31 stays 0 — WNALUserRequest - // does not validate it; it is informational/provider-filled). - for (int off = 0; off < capacity; off += sizeof(long)) - { - int remaining = capacity - off; - if (remaining >= sizeof(long)) Marshal.WriteInt64(buf, off, 0); - else for (int b = off; b < capacity; b++) Marshal.WriteByte(buf, b, 0); - } - - Marshal.WriteInt16(buf, 0, (short)(ushort)capacity); // WORD recSize - Marshal.WriteInt16(buf, 2, unchecked((short)AlmAckVersion)); // WORD version = 0x104 - - int rc = WnalReqSetText(buf, nodeName, oper, comment, passWd, domainName, fullName); - if (rc >= 0) - return buf; - - // rc < 0 => record too small; |rc| is the required size. Grow and retry. - capacity = -rc; - Marshal.FreeHGlobal(buf); - } - catch - { - Marshal.FreeHGlobal(buf); - throw; - } - } - - throw new InvalidOperationException("WNALalarmReqSetText could not size the ack record."); - } - - private static int EstimateCapacity(params string?[] fields) - { - int bytes = StringHeapBase; - foreach (string? f in fields) - bytes += (f is null ? 0 : Encoding.ASCII.GetByteCount(f)) + 1; - // Round up with headroom; records are tiny, over-allocation is cheap. - return Math.Max(1024, bytes + 64); - } - - // --------------------------------------------------------------------- - // LIST ACQUISITION — intentionally NOT implemented here, by RE conclusion. - // - // The ABIs of WNALSelectEntry (func 0x1d) and WNALGetAlarmRecordEx (func - // 0x1c) are recoverable, but their SEMANTICS are not: WNALGetAlarmRecordEx - // does not define the alarm-record layout — it hands alarmmgr/the provider a - // set of opaque output buffers (four 0x41-byte string buffers, a 0x8b-byte - // buffer, scalars) that the PROVIDER fills. So the record's GUID offset and - // the 4-byte alarm handle that WNALUserRequestEx consumes are provider-defined - // and cannot be read out of alarmlst.dll; likewise WNALSelectEntry's arg - // meanings live on the provider side of MakeAlarmMgrFuncCall. Hand-rolling the - // acquisition from static RE alone would be guessing which bytes are the - // handle/GUID — a wrong guess acks the wrong alarm. - // - // Therefore DO NOT complete the native acquisition by inference. Obtain the - // hAlarmList + handles from a component that already encapsulates - // register/query/match correctly: - // * Preferred: aaAlarmManagedClient.dll AlarmClient.AlarmAckByGUID - // (int AlarmAckByGUID(Guid, comment, oper, node, domain, fullName)) — the - // Alarm Control's own code; managed signatures are exact (no ABI risk). - // It is internal, so invoke by reflection from the x86 worker. - // * Or drive ack through the existing WnWrapAlarmConsumer, which already - // owns a registered/subscribed consumer, and fix the auth gap (Stage 0). - // Keep the confirmed SUBMIT methods above for the case where a real - // hAlarmList + handles are supplied by such a component. - // --------------------------------------------------------------------- -}