diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs new file mode 100644 index 0000000..ce400c4 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +public sealed class AlarmAckWriterTests +{ + private static AlarmAckOperator Alice() => + new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" }; + + private static RepeatedField Rows(MxStatusCategory category, int detail) => + new RepeatedField { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } }; + + [Fact] + public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0)); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.Applied, result.Outcome); + Assert.Equal(0, result.NativeStatus); + Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations); + Assert.Equal("pw-secret", server.LastPassword); + } + + [Fact] + public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache(); + server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008)); + using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome); + Assert.Equal(1008, result.NativeStatus); + Assert.Single(result.Statuses); + } + + [Fact] + public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome); + Assert.Equal(0, result.NativeStatus); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException) + { + RecordingAckServer server = new RecordingAckServer + { + AuthenticateResult = 0, + AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null, + }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway"); + + AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice()); + + Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome); + Assert.NotEqual(0, result.NativeStatus); + Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal)); + Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal); + Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret")); + } + + [Fact] + public void Acknowledge_Twice_ReusesItemHandle() + { + RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 }; + using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway"); + + writer.Acknowledge("T.A.AckMsg", "one", Alice()); + writer.Acknowledge("T.A.AckMsg", "two", Alice()); + + Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal))); + Assert.Equal(1, server.Operations.Count(op => op == "Advise")); + } + + [Fact] + public void AlarmAckOperator_ToString_OmitsPassword() + { + Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal); + } +} + +internal sealed class RecordingAckServer : IMxAccessServer +{ + private int nextItemHandle = 100; + + public List Operations { get; } = new(); + + public int AuthenticateResult { get; set; } + + public Exception? AuthenticateException { get; set; } + + public string? LastPassword { get; private set; } + + public Action? OnWrite { get; set; } + + public int Register(string clientName) + { + Operations.Add("Register:" + clientName); + return 7; + } + + public void Unregister(int serverHandle) => Operations.Add("Unregister"); + + public int AddItem(int serverHandle, string itemDefinition) + { + Operations.Add("AddItem:" + itemDefinition); + return nextItemHandle++; + } + + public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition); + + public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem"); + + public void Advise(int serverHandle, int itemHandle) => Operations.Add("Advise"); + + public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise"); + + public void AdviseSupervisory(int serverHandle, int itemHandle) + { + } + + public void Write(int serverHandle, int itemHandle, object? value, int userId) + { + Operations.Add("Write:" + value + ":" + userId); + OnWrite?.Invoke(serverHandle, itemHandle); + } + + public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId) + { + } + + public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) + { + } + + public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp) + { + } + + public object Suspend(int serverHandle, int itemHandle) => new object(); + + public object Activate(int serverHandle, int itemHandle) => new object(); + + public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword) + { + Operations.Add("AuthenticateUser:" + verifyUser); + LastPassword = verifyUserPassword; + if (AuthenticateException is not null) + { + throw AuthenticateException; + } + + return AuthenticateResult; + } + + public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0; + + public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition); + + public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) + { + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs new file mode 100644 index 0000000..5caee25 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs @@ -0,0 +1,24 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Operator identity an alarm acknowledgement authenticates as. The password +/// is a raw credential: it is passed to MXAccess AuthenticateUser only +/// and must never be logged or echoed. +/// +public sealed class AlarmAckOperator +{ + /// Gets or sets the operator user name. + public string UserName { get; set; } = string.Empty; + + /// Gets or sets the optional domain for OS-authenticated galaxies. + public string Domain { get; set; } = string.Empty; + + /// Gets or sets the operator password (credential-sensitive). + public string Password { get; set; } = string.Empty; + + /// Gets the user string passed to AuthenticateUser: Domain\UserName when a domain is set. + public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName; + + /// + public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")"; +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs new file mode 100644 index 0000000..9f4309a --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOutcome.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Outcome of an alarm acknowledgement attempt. +public enum AlarmAckOutcome +{ + /// The AckMsg write completed with an OK status. + Applied, + /// The write was sent but no OnWriteComplete arrived before the timeout. + Unconfirmed, + /// The alarm is not in the consumer's current view. + AlarmNotFound, + /// The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag). + NotAckable, + /// AuthenticateUser rejected the operator credentials. + AuthenticationFailed, + /// The AckMsg write completed with a non-OK status. + WriteRejected, +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs new file mode 100644 index 0000000..8f27c82 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckResult.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// Result of an alarm acknowledgement. +public sealed class AlarmAckResult +{ + private static readonly IReadOnlyList NoStatuses = new MxStatusProxy[0]; + + private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList? statuses) + { + Outcome = outcome; + NativeStatus = nativeStatus; + Diagnostic = diagnostic; + Statuses = statuses ?? NoStatuses; + } + + /// Gets the outcome. + public AlarmAckOutcome Outcome { get; } + + /// Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail. + public int NativeStatus { get; } + + /// Gets a diagnostic message. Never contains credentials. + public string Diagnostic { get; } + + /// Gets the OnWriteComplete statuses, empty when none was observed. + public IReadOnlyList Statuses { get; } + + /// Creates a result. + /// The outcome. + /// The native status. + /// A credential-free diagnostic. + /// Observed write statuses. + /// The result. + public static AlarmAckResult Create( + AlarmAckOutcome outcome, + int nativeStatus, + string diagnostic, + IReadOnlyList? statuses = null) => + new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses); + + /// Creates the result for an unresolved ack target. + /// The unresolved target. + /// The result. + public static AlarmAckResult FromTarget(AlarmAckTarget target) => + new AlarmAckResult(target.Failure, -1, target.Reason, null); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs new file mode 100644 index 0000000..89d28f5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckTarget.cs @@ -0,0 +1,42 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// The MXAccess item an acknowledgement writes, or why none exists. +public sealed class AlarmAckTarget +{ + private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason) + { + ItemAddress = itemAddress; + Failure = failure; + Reason = reason; + } + + /// Gets a value indicating whether an ack item was resolved. + public bool Found => ItemAddress.Length > 0; + + /// Gets the ack item address (e.g. TestMachine_001.TestAlarm001.AckMsg), empty when not found. + public string ItemAddress { get; } + + /// Gets the failure outcome when is false. + public AlarmAckOutcome Failure { get; } + + /// Gets a human-readable reason when is false. + public string Reason { get; } + + /// Creates a resolved target. + /// The ack item address. + /// The target. + public static AlarmAckTarget For(string itemAddress) => + new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty); + + /// Creates a not-found result. + /// Why the alarm was not found. + /// The target. + public static AlarmAckTarget NotFound(string reason) => + new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason); + + /// Creates a not-ackable result. + /// Why the alarm cannot be acknowledged over MXAccess. + /// The target. + public static AlarmAckTarget NotAckable(string reason) => + new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason); +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs new file mode 100644 index 0000000..87a20b5 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckWriter.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ArchestrA.MxAccess; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.Conversion; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Acknowledges alarms the way the AVEVA runtime records operator identity: +/// AuthenticateUser as the operator, then write the comment to the alarm's +/// AckMsg attribute with that user id. Owns its own +/// LMXProxyServerClass registered under the configured client name, +/// which the Historian records as the acknowledgement's OperatorNode. +/// Apartment-threaded: every call must run on the worker STA. The password +/// is passed to MXAccess only; it never reaches diagnostics. +/// +public sealed class AlarmAckWriter : IAlarmAckWriter +{ + /// The ArchestrA alarm-primitive attribute whose write acknowledges the alarm. + public const string AckCommentAttribute = "AckMsg"; + + /// Default MXAccess client name when none is configured. + public const string DefaultClientName = "MxGateway"; + + private readonly IMxAccessComObjectFactory? factory; + private readonly MxAccessWriteCompletionCache completionCache; + private readonly Action pumpStep; + private readonly TimeSpan completionTimeout; + private readonly string clientName; + private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter(); + private readonly Dictionary itemHandles = new Dictionary(StringComparer.OrdinalIgnoreCase); + private object? comObject; + private LMXProxyServerClass? comEventSource; + private IMxAccessServer? server; + private int serverHandle; + private bool disposed; + + /// Creates a writer that registers its own MXAccess server on first use. + /// Creates the LMXProxyServerClass. + /// Drains the STA message queue (OnWriteComplete is delivered by pumping). + /// Bounded wait for OnWriteComplete. + /// MXAccess client name (Historian OperatorNode). + public AlarmAckWriter( + IMxAccessComObjectFactory factory, + Action pumpStep, + TimeSpan completionTimeout, + string? clientName) + { + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); + this.completionTimeout = completionTimeout; + this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!; + completionCache = new MxAccessWriteCompletionCache(); + } + + /// Test seam: uses an already-constructed server and completion cache. + internal AlarmAckWriter( + IMxAccessServer server, + MxAccessWriteCompletionCache completionCache, + Action pumpStep, + TimeSpan completionTimeout, + string clientName) + { + this.server = server ?? throw new ArgumentNullException(nameof(server)); + this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache)); + this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep)); + this.completionTimeout = completionTimeout; + this.clientName = clientName; + serverHandle = server.Register(clientName); + } + + /// Resolves the configured client name from the launcher environment. + /// The client name, or . + public static string ResolveClientName() + { + string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName); + return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim(); + } + + /// + public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) + { + if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter)); + if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress)); + if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator)); + + IMxAccessServer mx = EnsureRegistered(); + + int userId; + try + { + userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty); + } + catch (COMException ex) + { + return AlarmAckResult.Create( + AlarmAckOutcome.AuthenticationFailed, + ex.HResult, + $"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8})."); + } + + if (userId == 0) + { + return AlarmAckResult.Create( + AlarmAckOutcome.AuthenticationFailed, + -1, + $"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned)."); + } + + if (!itemHandles.TryGetValue(itemAddress, out int itemHandle)) + { + itemHandle = mx.AddItem(serverHandle, itemAddress); + // MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG. + mx.Advise(serverHandle, itemHandle); + itemHandles[itemAddress] = itemHandle; + } + + pumpStep(); + ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle); + mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId); + + if (!completionCache.TryWaitForCompletion( + serverHandle, + itemHandle, + baseline, + DateTime.UtcNow + completionTimeout, + pumpStep, + out RepeatedField statuses)) + { + return AlarmAckResult.Create( + AlarmAckOutcome.Unconfirmed, + 0, + $"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms."); + } + + foreach (MxStatusProxy status in statuses) + { + if (status.Category != MxStatusCategory.Ok) + { + return AlarmAckResult.Create( + AlarmAckOutcome.WriteRejected, + status.Detail != 0 ? status.Detail : -1, + $"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.", + statuses); + } + } + + return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses); + } + + /// + public void Dispose() + { + if (disposed) return; + disposed = true; + + if (comEventSource is not null) + { + try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } + } + + IMxAccessServer? mx = server; + if (mx is not null) + { + foreach (int itemHandle in itemHandles.Values) + { + try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ } + try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ } + } + + try { mx.Unregister(serverHandle); } catch { /* best effort */ } + } + + itemHandles.Clear(); + server = null; + comEventSource = null; + object? toRelease = comObject; + comObject = null; + if (toRelease is not null && Marshal.IsComObject(toRelease)) + { + try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ } + } + } + + private IMxAccessServer EnsureRegistered() + { + if (server is not null) return server; + + object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null."); + try + { + comObject = created; + if (created is LMXProxyServerClass comServer) + { + comEventSource = comServer; + comServer.OnWriteComplete += OnWriteComplete; + } + + IMxAccessServer mx = new MxAccessComServer(created); + serverHandle = mx.Register(clientName); + server = mx; + return mx; + } + catch + { + if (comEventSource is not null) + { + try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ } + comEventSource = null; + } + + if (Marshal.IsComObject(created)) + { + try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ } + } + + comObject = null; + throw; + } + } + + private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars) + { + RepeatedField rows = new RepeatedField(); + rows.AddRange(statusConverter.ConvertMany(pVars)); + completionCache.Record(hLMXServerHandle, phItemHandle, rows); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs new file mode 100644 index 0000000..b5877be --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs @@ -0,0 +1,17 @@ +using System; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Acknowledges an alarm by writing its MXAccess ack item (AckMsg) +/// after authenticating as the operator. Must be called on the worker STA. +/// +public interface IAlarmAckWriter : IDisposable +{ + /// Authenticates and writes to . + /// The ack item, e.g. Obj.Alarm.AckMsg. + /// The acknowledgement comment. + /// The operator to authenticate as. + /// The outcome. + AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator); +}