6affbd57ea
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m19s
ci / java (push) Successful in 2m8s
ci / portable (push) Failing after 2m23s
ci / nightly-windev (pull_request) Has been skipped
ci / windows-x86 (pull_request) Successful in 1m18s
ci / java (pull_request) Successful in 2m3s
ci / portable (pull_request) Failing after 2m17s
Adds a managed acknowledge path over AVEVA's modern alarm client so GUID acks carry full operator identity (domain + full name) and work on builds where the wnwrap COM AlarmAckByGUID is E_NOTIMPL. Default off; opt in with MXGATEWAY_USE_MANAGED_ALARM_ACK=1 (wnwrap behavior unchanged otherwise). - AaManagedAlarmAckConsumer: reflection wrapper over aaAlarmManagedClient.AlarmClient (InitializeConsumer/RegisterConsumer/ Subscribe/AlarmAckByGUID); EnvGatedFactory degrades to null (wnwrap) if the DLL can't load, so alarm reads never break. - IManagedAlarmAckClient seam. AlarmDispatcher takes an optional client and routes GUID acks to it (subscribing + disposing it alongside the reader). - AlarmCommandHandler gains an optional managedAckFactory; WorkerPipeSession wires AaManagedAlarmAckConsumer.EnvGatedFactory at the production sites. - WnalNativeAckConsumer: confirmed native alarmlst.dll WNAL submit layer (WNALalarmReqSetText / WNALUserRequest / WNALUserRequestEx + lpAlmAck layout). List acquisition is intentionally not hand-rolled: the alarm-record layout and selection semantics are provider-defined and not recoverable from alarmlst.dll, so inferring them risks acking the wrong alarm. - AlarmManagedGuidAckLiveSmokeTests: skip-gated live smoke that pulls a real (non-degraded) alarm GUID and asserts the ack APPLIES (Acknowledge transition / acked snapshot), not merely rc=0. By-name acks stay on wnwrap (AlarmClient has no AlarmAckByName). Grounded in static RE of alarmlst.dll, aaAlarmManagedClient.dll and alarmmgr.exe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
10 KiB
C#
230 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
|
|
/// <see cref="AaManagedAlarmAckConsumer"/> wired into
|
|
/// <see cref="AlarmDispatcher"/>. Where <c>AlarmsLiveSmokeTests</c> acks by
|
|
/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
|
|
/// modern <c>aaAlarmManagedClient.AlarmClient.AlarmAckByGUID</c> — the path
|
|
/// that carries operator domain + full name and is not <c>E_NOTIMPL</c>.
|
|
///
|
|
/// Flow: subscribe a wnwrap reader + a managed ack client to
|
|
/// <c>\\<machine>\Galaxy!DEV</c>; 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 <see cref="AlarmTransitionKind.Acknowledge"/>
|
|
/// 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 <c>Skip=null</c> on the dev rig with AVEVA +
|
|
/// aaAlarmManagedClient.dll present and the flip script running.
|
|
/// </summary>
|
|
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<string> log = new ConcurrentQueue<string>();
|
|
|
|
/// <summary>Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.</summary>
|
|
/// <param name="output">Test output helper for logging.</param>
|
|
public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
|
|
{
|
|
this.output = output;
|
|
}
|
|
|
|
/// <summary>Acks a live alarm by GUID through the managed client and verifies the acked transition.</summary>
|
|
[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}");
|
|
}
|