Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e988ede88 | |||
| e7f56622c1 | |||
| 1998ee3e20 | |||
| 027cc7393d | |||
| 7b81cb7f72 | |||
| 0143591858 | |||
| e8ace39418 | |||
| 9907a97cb0 | |||
| 84e04b6552 | |||
| 417a47bdb5 | |||
| cfe9f817a3 | |||
| 9409488008 | |||
| 2858e42e80 | |||
| d6db0ff40d | |||
| 0d4b2aea2d | |||
| 8199553734 | |||
| 7900df3dce | |||
| 76db822df3 | |||
| 64dc1215fd | |||
| a94b502ce6 | |||
| bf4b9c6c09 |
@@ -126,7 +126,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `docs/DesignDecisions.md` (Session-Resilience Epic Scope, session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
|
||||
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` credentials, and `AcknowledgeAlarm` `operator_password` must never reach logs. Value logging is opt-in and redacted.
|
||||
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||
|
||||
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
|
||||
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
|
||||
the active alarms the gateway's central monitor currently holds),
|
||||
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
|
||||
reference, optional comment, ack target). All three accept a cancellation
|
||||
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
|
||||
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
|
||||
writes the comment to the alarm's `AckMsg`, so the Historian records the
|
||||
operator). All three accept a cancellation
|
||||
token and pass through the `MxGateway:Alarms` configuration on the
|
||||
server — when alarms are disabled, the gateway returns an empty list / empty
|
||||
stream rather than failing.
|
||||
@@ -291,10 +293,15 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --se
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 4 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||
```
|
||||
|
||||
`acknowledge-alarm` reads the operator password from `--operator-password` or the
|
||||
environment variable named by `--operator-password-env` (default
|
||||
`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from
|
||||
any error output. Prefer the environment variable to keep it off the command line.
|
||||
|
||||
`smoke` opens a session, registers a client, adds one item, advises it,
|
||||
optionally writes a value when `--type` and `--value` are supplied, reads a
|
||||
bounded event stream, and closes the session in a `finally` block. CLI error
|
||||
|
||||
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
|
||||
exception.Message,
|
||||
apiKey,
|
||||
TryResolveVerifyUserPassword(arguments),
|
||||
ResolveOperatorPassword(arguments),
|
||||
arguments.GetOptional("value"));
|
||||
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
@@ -352,6 +353,12 @@ public static class MxGatewayClientCli
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Default environment variable holding the operator password for
|
||||
/// <c>acknowledge-alarm</c> when <c>--operator-password-env</c> is not given.
|
||||
/// </summary>
|
||||
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Legacy environment variable, still honoured as a deprecated fallback
|
||||
/// for one release so existing scripts keep working.
|
||||
@@ -378,6 +385,30 @@ public static class MxGatewayClientCli
|
||||
: environmentName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>acknowledge-alarm</c> operator password: <c>--operator-password</c>,
|
||||
/// then the environment variable named by <c>--operator-password-env</c>
|
||||
/// (default <c>MXGATEWAY_OPERATOR_PASSWORD</c>). An empty value is treated as
|
||||
/// absent. The value is never echoed; it is also fed to the error redactor.
|
||||
/// </summary>
|
||||
private static string? ResolveOperatorPassword(CliArguments arguments)
|
||||
{
|
||||
string? password = arguments.GetOptional("operator-password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
return password;
|
||||
}
|
||||
|
||||
string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(environmentName))
|
||||
{
|
||||
environmentName = OperatorPasswordEnvironmentName;
|
||||
}
|
||||
|
||||
password = Environment.GetEnvironmentVariable(environmentName);
|
||||
return string.IsNullOrEmpty(password) ? null : password;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in this
|
||||
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
|
||||
@@ -1535,6 +1566,8 @@ public static class MxGatewayClientCli
|
||||
AlarmFullReference = arguments.GetRequired("reference"),
|
||||
Comment = arguments.GetOptional("comment") ?? string.Empty,
|
||||
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
|
||||
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
|
||||
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
|
||||
};
|
||||
|
||||
return WriteReplyAsync(
|
||||
@@ -2465,7 +2498,7 @@ public static class MxGatewayClientCli
|
||||
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
|
||||
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
|
||||
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
|
||||
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
|
||||
|
||||
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = "investigating",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
OperatorDomain = "CORP",
|
||||
});
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
@@ -45,9 +47,35 @@ public sealed class MxGatewayClientAlarmsTests
|
||||
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
|
||||
Assert.Equal("investigating", call.Request.Comment);
|
||||
Assert.Equal("alice", call.Request.OperatorUser);
|
||||
Assert.Equal("pw-secret", call.Request.OperatorPassword);
|
||||
Assert.Equal("CORP", call.Request.OperatorDomain);
|
||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync redacts the operator password from a failing call's message.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
|
||||
// The real transport maps RpcException to MxGatewayException using the status
|
||||
// detail; the fake passes exceptions verbatim, so enqueue the mapped shape.
|
||||
transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret"));
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
|
||||
MxGatewayException ex = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "pw-secret",
|
||||
}));
|
||||
|
||||
Assert.DoesNotContain("pw-secret", ex.Message);
|
||||
Assert.Contains("boom", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -763,6 +763,7 @@ public sealed class MxGatewayClientCliTests
|
||||
[Fact]
|
||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
@@ -786,6 +787,10 @@ public sealed class MxGatewayClientCliTests
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
@@ -797,6 +802,9 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
|
||||
Assert.Equal("ack from cli", request.Comment);
|
||||
Assert.Equal("operator1", request.OperatorUser);
|
||||
Assert.Equal("CORP", request.OperatorDomain);
|
||||
Assert.Equal("op-pw-secret", request.OperatorPassword);
|
||||
Assert.DoesNotContain("op-pw-secret", output.ToString());
|
||||
Assert.Contains("ack-fixture", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
||||
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
|
||||
/// scope and forwards the acknowledge to the worker's MXAccess session;
|
||||
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
||||
/// Acknowledges an alarm as the operator. The gateway authenticates
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
|
||||
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
|
||||
/// comment to the alarm's AckMsg attribute, so the alarm history records
|
||||
/// the operator. The password is redacted from any surfaced error.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
||||
/// <param name="request">The acknowledge request: alarm reference, comment, operator credentials.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ThrowIfDisposed();
|
||||
|
||||
return ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken);
|
||||
try
|
||||
{
|
||||
return await ExecuteSafeUnaryAsync(
|
||||
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
|
||||
{
|
||||
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it.
|
||||
|
||||
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
|
||||
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
|
||||
`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the
|
||||
operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`,
|
||||
optional `OperatorDomain`) so the gateway authenticates as the operator and the
|
||||
Historian records that operator; an empty password is allowed, and the password
|
||||
is scrubbed from any returned error. The streaming
|
||||
call returns a `StreamAlarmsClient`; cancel its context to terminate the
|
||||
stream. All three pass straight through to the gateway's central alarm
|
||||
monitor.
|
||||
@@ -424,7 +428,7 @@ Every subcommand wired into the CLI. All accept the common flags
|
||||
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
|
||||
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
|
||||
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm reference. |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). |
|
||||
| `smoke` | End-to-end smoke workflow against one item. |
|
||||
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
|
||||
| `galaxy-last-deploy` | Print the most recent deploy event. |
|
||||
|
||||
@@ -1119,6 +1119,10 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOperatorPasswordEnv is the CLI environment variable holding the
|
||||
// operator password for acknowledge-alarm.
|
||||
const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -1126,7 +1130,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
reference := flags.String("reference", "", "full alarm reference to acknowledge")
|
||||
comment := flags.String("comment", "", "operator acknowledge comment")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge")
|
||||
operator := flags.String("operator", "", "operator user performing the acknowledge (required)")
|
||||
operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)")
|
||||
// Prefer the environment variable so the credential stays out of shell
|
||||
// history and the process table; an empty password is allowed.
|
||||
operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)")
|
||||
operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
if *reference == "" {
|
||||
return errors.New("reference is required")
|
||||
}
|
||||
if *operator == "" {
|
||||
return errors.New("operator is required")
|
||||
}
|
||||
resolvedOperatorPassword := *operatorPassword
|
||||
if resolvedOperatorPassword == "" {
|
||||
envName := *operatorPasswordEnv
|
||||
if envName == "" {
|
||||
envName = defaultOperatorPasswordEnv
|
||||
}
|
||||
resolvedOperatorPassword = os.Getenv(envName)
|
||||
}
|
||||
|
||||
client, options, err := dialForCommand(ctx, common)
|
||||
if err != nil {
|
||||
@@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
|
||||
AlarmFullReference: *reference,
|
||||
Comment: *comment,
|
||||
OperatorUser: *operator,
|
||||
OperatorDomain: *operatorDomain,
|
||||
OperatorPassword: resolvedOperatorPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
type AcknowledgeAlarmCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
@@ -3596,6 +3599,10 @@ type AcknowledgeAlarmCommand struct {
|
||||
OperatorNode string `protobuf:"bytes,4,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
|
||||
OperatorDomain string `protobuf:"bytes,5,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
OperatorFullName string `protobuf:"bytes,6,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,7,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
// the snapshot to alarms whose alarm_full_reference starts with the
|
||||
// supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix).
|
||||
@@ -3721,9 +3735,7 @@ func (x *QueryActiveAlarmsCommand) GetAlarmFilterPrefix() string {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
type AcknowledgeAlarmByNameCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
@@ -3739,6 +3751,10 @@ type AcknowledgeAlarmByNameCommand struct {
|
||||
OperatorNode string `protobuf:"bytes,6,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
|
||||
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
OperatorFullName string `protobuf:"bytes,8,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,9,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UnsubscribeBulkCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -6127,11 +6150,11 @@ func (x *DrainEventsReply) GetEvents() []*MxEvent {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
type AcknowledgeAlarmReplyPayload struct {
|
||||
@@ -7217,9 +7240,18 @@ type AcknowledgeAlarmRequest struct {
|
||||
Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"`
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
OperatorPassword string `protobuf:"bytes,6,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) Reset() {
|
||||
@@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) GetOperatorPassword() string {
|
||||
if x != nil {
|
||||
return x.OperatorPassword
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AcknowledgeAlarmRequest) GetOperatorDomain() string {
|
||||
if x != nil {
|
||||
return x.OperatorDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type AcknowledgeAlarmReply struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"`
|
||||
ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"`
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
||||
@@ -8992,7 +9037,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\x13AlarmFailoverConfig\x12B\n" +
|
||||
"\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" +
|
||||
"\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\n" +
|
||||
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xf3\x01\n" +
|
||||
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xa0\x02\n" +
|
||||
"\x17AcknowledgeAlarmCommand\x12\x1d\n" +
|
||||
"\n" +
|
||||
"alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\n" +
|
||||
@@ -9000,9 +9045,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\roperator_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" +
|
||||
"\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" +
|
||||
"\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\x12,\n" +
|
||||
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\"J\n" +
|
||||
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\x12+\n" +
|
||||
"\x11operator_password\x18\a \x01(\tR\x10operatorPassword\"J\n" +
|
||||
"\x18QueryActiveAlarmsCommand\x12.\n" +
|
||||
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xbd\x02\n" +
|
||||
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xea\x02\n" +
|
||||
"\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" +
|
||||
"\n" +
|
||||
"alarm_name\x18\x01 \x01(\tR\talarmName\x12#\n" +
|
||||
@@ -9013,7 +9059,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" +
|
||||
"\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" +
|
||||
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\x12,\n" +
|
||||
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\"`\n" +
|
||||
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\x12+\n" +
|
||||
"\x11operator_password\x18\t \x01(\tR\x10operatorPassword\"`\n" +
|
||||
"\x16UnsubscribeBulkCommand\x12#\n" +
|
||||
"\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" +
|
||||
"\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" +
|
||||
@@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
|
||||
"limitValue\x12\x1a\n" +
|
||||
"\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" +
|
||||
"\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" +
|
||||
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" +
|
||||
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xa6\x02\n" +
|
||||
"\x17AcknowledgeAlarmRequest\x122\n" +
|
||||
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
|
||||
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
|
||||
"\acomment\x18\x04 \x01(\tR\acomment\x12#\n" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" +
|
||||
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12+\n" +
|
||||
"\x11operator_password\x18\x06 \x01(\tR\x10operatorPassword\x12'\n" +
|
||||
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomainJ\x04\b\x01\x10\x02R\n" +
|
||||
"session_id\"\xb4\x02\n" +
|
||||
"\x15AcknowledgeAlarmReply\x12%\n" +
|
||||
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
|
||||
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
|
||||
// session; the resulting native MxStatus is returned in the reply.
|
||||
//
|
||||
// OperatorUser is required: the gateway authenticates the operator (with
|
||||
// OperatorPassword and optional OperatorDomain) so the Historian records the
|
||||
// real operator. An empty password is allowed. The password is scrubbed from
|
||||
// any error this method returns.
|
||||
//
|
||||
// Acks are idempotent — re-acking an already-acked condition is a no-op at
|
||||
// the MxAccess layer.
|
||||
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
|
||||
@@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
|
||||
|
||||
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
|
||||
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
|
||||
}
|
||||
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
||||
return reply, err
|
||||
return reply, redactSecrets(err, req.GetOperatorPassword())
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
@@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
Comment: "investigating",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
OperatorDomain: "CORP",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AcknowledgeAlarm() error = %v", err)
|
||||
@@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
|
||||
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
|
||||
t.Fatalf("captured comment = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" {
|
||||
t.Fatalf("captured operator password mismatch")
|
||||
}
|
||||
if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" {
|
||||
t.Fatalf("captured operator domain = %q", got)
|
||||
}
|
||||
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
|
||||
t.Fatalf("authorization metadata = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{
|
||||
acknowledgeError: status.Error(codes.Internal, "boom pw-secret"),
|
||||
}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
OperatorUser: "alice",
|
||||
OperatorPassword: "pw-secret",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AcknowledgeAlarm() returned no error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "pw-secret") {
|
||||
t.Fatalf("error leaks operator password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
|
||||
fake := &fakeGatewayWithAlarms{}
|
||||
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||
|
||||
@@ -114,8 +114,11 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
|
||||
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
|
||||
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
|
||||
yields alarm-feed messages from the gateway's central monitor), and
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
|
||||
ack target). Close the subscription to cancel the underlying gRPC stream.
|
||||
`acknowledgeAlarm` (ack by full alarm reference with an optional comment).
|
||||
The ack carries operator credentials: `operator_user` is required, and
|
||||
`operator_password` / `operator_domain` let the gateway authenticate as that
|
||||
operator so the Historian records the real operator. An empty password is
|
||||
allowed. The password is scrubbed from exceptions the client throws. Close the subscription to cancel the underlying gRPC stream.
|
||||
|
||||
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
|
||||
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
|
||||
@@ -417,7 +420,7 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localho
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 4 --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json"
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||
```
|
||||
|
||||
|
||||
+1261
-447
File diff suppressed because it is too large
Load Diff
+27
@@ -1822,6 +1822,31 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
|
||||
String operator = "";
|
||||
|
||||
@Option(names = "--operator-domain", description = "Operator Galaxy domain (optional).")
|
||||
String operatorDomain = "";
|
||||
|
||||
@Option(
|
||||
names = "--operator-password",
|
||||
description = "Operator Galaxy password (credential; prefer --operator-password-env). Never echoed.")
|
||||
String operatorPassword;
|
||||
|
||||
@Option(
|
||||
names = "--operator-password-env",
|
||||
defaultValue = "MXGATEWAY_OPERATOR_PASSWORD",
|
||||
description = "Environment variable holding the operator password when --operator-password is omitted.")
|
||||
String operatorPasswordEnv;
|
||||
|
||||
private String resolveOperatorPassword() {
|
||||
if (operatorPassword != null && !operatorPassword.isEmpty()) {
|
||||
return operatorPassword;
|
||||
}
|
||||
String name = operatorPasswordEnv == null || operatorPasswordEnv.isBlank()
|
||||
? "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
: operatorPasswordEnv;
|
||||
String value = System.getenv(name);
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
|
||||
super(clientFactory);
|
||||
}
|
||||
@@ -1833,6 +1858,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
.setAlarmFullReference(reference)
|
||||
.setComment(comment)
|
||||
.setOperatorUser(operator)
|
||||
.setOperatorDomain(operatorDomain == null ? "" : operatorDomain)
|
||||
.setOperatorPassword(resolveOperatorPassword())
|
||||
.build());
|
||||
writeOutput(
|
||||
"acknowledge-alarm",
|
||||
|
||||
+11
@@ -714,9 +714,16 @@ final class MxGatewayCliTests {
|
||||
"checked",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password",
|
||||
"pw-secret",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("CORP", factory.client.lastAcknowledgeAlarmRequest.getOperatorDomain());
|
||||
assertEquals("pw-secret", factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword());
|
||||
assertFalse(run.output().contains("pw-secret"), run.output());
|
||||
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||
@@ -770,6 +777,10 @@ final class MxGatewayCliTests {
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-password-env",
|
||||
"MXGATEWAY_OPERATOR_PASSWORD",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
|
||||
+27
-4
@@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
* worker's MXAccess session; the resulting native MxStatus is returned
|
||||
* in the reply. Acks are idempotent at the MxAccess layer.
|
||||
*
|
||||
* <p>{@code operator_user} is required; the gateway authenticates it with
|
||||
* {@code operator_password} (and optional {@code operator_domain}) and the
|
||||
* Historian records that operator. An empty password is allowed. The
|
||||
* password is scrubbed from any exception this method throws.
|
||||
*
|
||||
* @param request the {@code AcknowledgeAlarmRequest}
|
||||
* @return the raw acknowledge reply
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
@@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
|
||||
? gatewayError
|
||||
: (MxGatewayException) MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
String secret = request.getOperatorPassword();
|
||||
if (secret.isEmpty()) {
|
||||
throw mapped;
|
||||
}
|
||||
throw MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
||||
throw MxGatewayErrors.redacted(mapped, secret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,10 +308,24 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
*/
|
||||
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
|
||||
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(request));
|
||||
return future.thenApply(reply -> {
|
||||
String secret = request.getOperatorPassword();
|
||||
CompletableFuture<AcknowledgeAlarmReply> checked = future.thenApply(reply -> {
|
||||
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
|
||||
return reply;
|
||||
});
|
||||
if (secret.isEmpty()) {
|
||||
return checked;
|
||||
}
|
||||
// Scrub the operator password from any failure surfaced through the future.
|
||||
return checked.exceptionally(error -> {
|
||||
Throwable cause = error instanceof java.util.concurrent.CompletionException && error.getCause() != null
|
||||
? error.getCause()
|
||||
: error;
|
||||
if (cause instanceof MxGatewayException gatewayError) {
|
||||
throw new java.util.concurrent.CompletionException(MxGatewayErrors.redacted(gatewayError, secret));
|
||||
}
|
||||
throw new java.util.concurrent.CompletionException(cause);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
@@ -28,6 +28,43 @@ final class MxGatewayErrors {
|
||||
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a gateway exception with an exact secret scrubbed from its
|
||||
* message and protocol status. The original is not chained as a cause
|
||||
* because its message and cause chain may carry the secret.
|
||||
*/
|
||||
static MxGatewayException redacted(MxGatewayException error, String secret) {
|
||||
String message = MxGatewaySecrets.redactExact(error.getMessage(), secret);
|
||||
if (error instanceof MxAccessException ex) {
|
||||
return new MxAccessException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewayCommandException ex) {
|
||||
return new MxGatewayCommandException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
|
||||
}
|
||||
if (error instanceof MxGatewaySessionException ex) {
|
||||
return new MxGatewaySessionException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayWorkerException ex) {
|
||||
return new MxGatewayWorkerException(message, scrub(ex.protocolStatus(), secret), null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthenticationException) {
|
||||
return new MxGatewayAuthenticationException(message, null);
|
||||
}
|
||||
if (error instanceof MxGatewayAuthorizationException) {
|
||||
return new MxGatewayAuthorizationException(message, null);
|
||||
}
|
||||
return new MxGatewayException(message);
|
||||
}
|
||||
|
||||
private static ProtocolStatus scrub(ProtocolStatus status, String secret) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
return status.toBuilder()
|
||||
.setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secret))
|
||||
.build();
|
||||
}
|
||||
|
||||
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
|
||||
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
|
||||
return;
|
||||
|
||||
+46
@@ -74,6 +74,52 @@ final class MxGatewayCredentialReplyTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmAsyncRedactsOperatorPasswordFromFailure() throws Exception {
|
||||
String serverName = "mxgw-java-ack-" + UUID.randomUUID();
|
||||
MxAccessGatewayGrpc.MxAccessGatewayImplBase service = new MxAccessGatewayGrpc.MxAccessGatewayImplBase() {
|
||||
@Override
|
||||
public void acknowledgeAlarm(
|
||||
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
|
||||
StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> observer) {
|
||||
observer.onNext(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.newBuilder()
|
||||
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
|
||||
.setMessage("auth failed for operator1 password " + CREDENTIAL))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
}
|
||||
};
|
||||
Server server = InProcessServerBuilder.forName(serverName)
|
||||
.directExecutor().addService(service).build().start();
|
||||
ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
|
||||
try (MxGatewayClient client = new MxGatewayClient(
|
||||
channel,
|
||||
MxGatewayClientOptions.builder()
|
||||
.endpoint("in-process").apiKey("").plaintext(true)
|
||||
.callTimeout(Duration.ofSeconds(5)).build())) {
|
||||
var request = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.newBuilder()
|
||||
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||
.setOperatorUser("operator1")
|
||||
.setOperatorPassword(CREDENTIAL)
|
||||
.build();
|
||||
|
||||
java.util.concurrent.ExecutionException async = assertThrows(
|
||||
java.util.concurrent.ExecutionException.class,
|
||||
() -> client.acknowledgeAlarmAsync(request).get());
|
||||
assertTrue(async.getCause() instanceof MxGatewayException, String.valueOf(async.getCause()));
|
||||
assertFalse(async.getCause().getMessage().contains(CREDENTIAL));
|
||||
assertFalse(((MxGatewayCommandException) async.getCause()).protocolStatus().getMessage()
|
||||
.contains(CREDENTIAL));
|
||||
|
||||
MxGatewayException sync = assertThrows(MxGatewayException.class, () -> client.acknowledgeAlarm(request));
|
||||
assertFalse(sync.getMessage().contains(CREDENTIAL));
|
||||
} finally {
|
||||
channel.shutdownNow();
|
||||
server.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception {
|
||||
MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json");
|
||||
|
||||
Binary file not shown.
@@ -111,9 +111,15 @@ MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
|
||||
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
|
||||
messages from the gateway's central monitor), and
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
|
||||
and ack target). Cancel the surrounding task or `aclose()` the iterator to
|
||||
terminate the stream.
|
||||
`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
|
||||
comment). The ack is made as the operator: set `operator_user` (required) and
|
||||
`operator_password` (may be empty) and optionally `operator_domain` on
|
||||
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
|
||||
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
|
||||
password is redacted from any raised error.
|
||||
|
||||
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
|
||||
stream.
|
||||
|
||||
`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
@@ -457,7 +463,7 @@ mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --j
|
||||
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
|
||||
mxgw-py stream-events --session-id <id> --max-events 1 --json
|
||||
mxgw-py stream-alarms --max-messages 4 --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
|
||||
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
|
||||
```
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import grpc
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .errors import ensure_protocol_success, map_rpc_error
|
||||
from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error
|
||||
from .generated import mxaccess_gateway_pb2 as pb
|
||||
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
from .options import ClientOptions, create_channel, resolve_channel_security
|
||||
@@ -148,17 +148,23 @@ class GatewayClient:
|
||||
self,
|
||||
request: pb.AcknowledgeAlarmRequest,
|
||||
) -> pb.AcknowledgeAlarmReply:
|
||||
"""Acknowledge an active MXAccess alarm condition through the gateway.
|
||||
"""Acknowledge an alarm as the operator.
|
||||
|
||||
The gateway authenticates the request against the API key's
|
||||
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
|
||||
MXAccess session; the resulting native ``MxStatus`` is returned in the
|
||||
reply. Acks are idempotent — re-acking an already-acked condition is a
|
||||
no-op at the MxAccess layer.
|
||||
The gateway authenticates ``operator_user`` with ``operator_password`` and
|
||||
writes the comment to the alarm's ``AckMsg``, so the alarm history records
|
||||
the operator. The password is redacted from any raised error.
|
||||
"""
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
try:
|
||||
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
||||
return reply
|
||||
except MxGatewayError as error:
|
||||
if request.operator_password:
|
||||
# Imported lazily: session.py imports this module at its tail.
|
||||
from .session import _redact_error
|
||||
|
||||
_redact_error(error, [request.operator_password])
|
||||
raise
|
||||
|
||||
def query_active_alarms(
|
||||
self,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from .auth import redact_secret
|
||||
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
|
||||
@@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
||||
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
||||
|
||||
|
||||
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
|
||||
def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any:
|
||||
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
||||
|
||||
Operates on a clone so the caller's original reply object is never mutated.
|
||||
Only the free-text diagnostic fields that can echo a client-supplied secret
|
||||
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
||||
Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...):
|
||||
each field is scrubbed only when the message type declares it.
|
||||
"""
|
||||
clone = type(reply)()
|
||||
clone.CopyFrom(reply)
|
||||
if clone.protocol_status.message:
|
||||
fields = clone.DESCRIPTOR.fields_by_name
|
||||
if "protocol_status" in fields and clone.protocol_status.message:
|
||||
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
||||
if clone.diagnostic_message:
|
||||
if "diagnostic_message" in fields and clone.diagnostic_message:
|
||||
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
if "statuses" in fields:
|
||||
for status in clone.statuses:
|
||||
if status.diagnostic_text:
|
||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||
return clone
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
|
||||
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
|
||||
#: five languages.
|
||||
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
|
||||
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
|
||||
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
@@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
|
||||
@gateway_options
|
||||
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
|
||||
@click.option("--comment", default="", help="Acknowledgement comment.")
|
||||
@click.option("--operator", default="", help="Operator user name.")
|
||||
@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).")
|
||||
@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).")
|
||||
@click.option(
|
||||
"--operator-password",
|
||||
default=None,
|
||||
help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.",
|
||||
)
|
||||
@click.option(
|
||||
"--operator-password-env",
|
||||
default=DEFAULT_OPERATOR_PASSWORD_ENV,
|
||||
show_default=True,
|
||||
help="Environment variable holding the operator password (an unset variable means an empty password).",
|
||||
)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def acknowledge_alarm(**kwargs: Any) -> None:
|
||||
"""Acknowledge an active MXAccess alarm condition (session-less)."""
|
||||
"""Acknowledge an active MXAccess alarm condition as the operator (session-less)."""
|
||||
|
||||
password = _resolve_operator_password(kwargs)
|
||||
kwargs["operator_password"] = password
|
||||
_run(
|
||||
_acknowledge_alarm(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
secrets=_secrets(kwargs) + [password],
|
||||
)
|
||||
|
||||
|
||||
@@ -836,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"userId": user_id}
|
||||
|
||||
|
||||
def _resolve_operator_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the alarm-ack operator password from --operator-password or its env var.
|
||||
|
||||
Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy
|
||||
accounts may have empty passwords. The value is never echoed; callers add it
|
||||
to the ``secrets`` redaction list.
|
||||
"""
|
||||
|
||||
env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV
|
||||
password = kwargs.get("operator_password")
|
||||
if password is None:
|
||||
password = os.environ.get(env_name, "")
|
||||
return password
|
||||
|
||||
|
||||
def _resolve_password(kwargs: dict[str, Any]) -> str:
|
||||
"""Resolve the authenticate-user password from --password or --password-env.
|
||||
|
||||
@@ -1161,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
alarm_full_reference=kwargs["reference"],
|
||||
comment=kwargs["comment"],
|
||||
operator_user=kwargs["operator"],
|
||||
operator_domain=kwargs["operator_domain"],
|
||||
operator_password=kwargs["operator_password"],
|
||||
),
|
||||
)
|
||||
return _message_dict(reply)
|
||||
|
||||
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
comment="investigating",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
operator_domain="CORP",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,9 +46,68 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.exception = FakeRpcError(
|
||||
grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret",
|
||||
)
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayAuthenticationError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None:
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||
|
||||
stub = FakeGatewayStub()
|
||||
stub.acknowledge_alarm.replies = [
|
||||
pb.AcknowledgeAlarmReply(
|
||||
protocol_status=pb.ProtocolStatus(
|
||||
code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST,
|
||||
message="rejected credential pw-secret",
|
||||
),
|
||||
),
|
||||
]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(MxGatewayError) as excinfo:
|
||||
await client.acknowledge_alarm(
|
||||
pb.AcknowledgeAlarmRequest(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
operator_user="alice",
|
||||
operator_password="pw-secret",
|
||||
),
|
||||
)
|
||||
|
||||
assert "pw-secret" not in str(excinfo.value)
|
||||
raw = excinfo.value.raw_reply
|
||||
assert isinstance(raw, pb.AcknowledgeAlarmReply)
|
||||
assert "pw-secret" not in raw.protocol_status.message
|
||||
assert "pw-secret" not in str(raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
|
||||
@@ -771,6 +771,7 @@ def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) ->
|
||||
|
||||
|
||||
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
|
||||
stub = _BulkFakeStub()
|
||||
stub.acknowledge_alarm = _BulkFakeUnary(
|
||||
[
|
||||
@@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
"investigating",
|
||||
"--operator",
|
||||
"alice",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "pw-secret" not in result.output
|
||||
captured_request = stub.acknowledge_alarm.requests[0]
|
||||
assert captured_request.operator_domain == "CORP"
|
||||
assert captured_request.operator_password == "pw-secret"
|
||||
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
|
||||
assert captured_request.comment == "investigating"
|
||||
assert captured_request.operator_user == "alice"
|
||||
|
||||
@@ -78,7 +78,8 @@ cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --
|
||||
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
||||
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --reference "Area001.Pump001.PumpFault" --operator <user> [--operator-domain <domain>] --json
|
||||
# operator password: MXGATEWAY_OPERATOR_PASSWORD, or --operator-password-env / --operator-password
|
||||
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
||||
```
|
||||
|
||||
@@ -124,6 +125,9 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
|
||||
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
|
||||
returns an `AlarmFeedStream` async stream of alarm-feed messages and
|
||||
shares the gateway's central alarm monitor with every other client.
|
||||
`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be
|
||||
empty) and `operator_domain`; the gateway authenticates as that operator so the Historian
|
||||
records them. The password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
|
||||
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
|
||||
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
|
||||
|
||||
@@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may
|
||||
attach. Dropping the stream cancels the gRPC call cooperatively.
|
||||
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
|
||||
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
|
||||
`operator_user` is required; `operator_password` may be empty and is scrubbed from any
|
||||
returned error via `Error::redact_secrets`.
|
||||
|
||||
## Galaxy Repository
|
||||
|
||||
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
|
||||
mxgw write-secured2-bulk ...
|
||||
mxgw stream-events --session-id <id> --json
|
||||
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
|
||||
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] --operator <user> [--operator-domain <domain>] [--operator-password-env <VAR>=MXGATEWAY_OPERATOR_PASSWORD] [--operator-password <pw>]
|
||||
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
|
||||
@@ -374,8 +374,19 @@ enum Command {
|
||||
reference: String,
|
||||
#[arg(long, default_value = "")]
|
||||
comment: String,
|
||||
#[arg(long, default_value = "")]
|
||||
/// Galaxy account of the acknowledging operator (required; the gateway
|
||||
/// rejects an empty operator so the real operator is recorded).
|
||||
#[arg(long)]
|
||||
operator: String,
|
||||
/// Galaxy domain of the operator; empty authenticates the bare user name.
|
||||
#[arg(long, default_value = "")]
|
||||
operator_domain: String,
|
||||
/// Operator password (prefer `--operator-password-env`; empty is allowed).
|
||||
#[arg(long)]
|
||||
operator_password: Option<String>,
|
||||
/// Environment variable holding the operator password.
|
||||
#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")]
|
||||
operator_password_env: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
@@ -1081,8 +1092,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
reference,
|
||||
comment,
|
||||
operator,
|
||||
operator_domain,
|
||||
operator_password,
|
||||
operator_password_env,
|
||||
json,
|
||||
} => {
|
||||
// An empty password is allowed (Galaxy accounts may have one), so
|
||||
// an unset flag and env resolve to an empty string.
|
||||
let operator_password = operator_password
|
||||
.or_else(|| env::var(&operator_password_env).ok())
|
||||
.unwrap_or_default();
|
||||
let client = connect(connection).await?;
|
||||
let reply = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
@@ -1090,6 +1109,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
alarm_full_reference: reference,
|
||||
comment,
|
||||
operator_user: operator,
|
||||
operator_password,
|
||||
operator_domain,
|
||||
})
|
||||
.await?;
|
||||
print_acknowledge_alarm_reply(&reply, json);
|
||||
@@ -2771,6 +2792,17 @@ mod tests {
|
||||
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_operator() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
"mxgw",
|
||||
"acknowledge-alarm",
|
||||
"--reference",
|
||||
"Tank01.Level.HiHi",
|
||||
]);
|
||||
assert!(parsed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledge_alarm_requires_reference() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
|
||||
|
||||
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
|
||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
message AcknowledgeAlarmCommand {
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
string alarm_guid = 1;
|
||||
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
|
||||
string operator_node = 4;
|
||||
string operator_domain = 5;
|
||||
string operator_full_name = 6;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 7;
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
message AcknowledgeAlarmByNameCommand {
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
// may contain dots; the gateway-side parser splits on the first dot
|
||||
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
|
||||
string operator_node = 6;
|
||||
string operator_domain = 7;
|
||||
string operator_full_name = 8;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 9;
|
||||
}
|
||||
|
||||
message UnsubscribeBulkCommand {
|
||||
@@ -725,11 +734,11 @@ message DrainEventsReply {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
message AcknowledgeAlarmReplyPayload {
|
||||
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
string operator_user = 5;
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
string operator_password = 6;
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
string operator_domain = 7;
|
||||
}
|
||||
|
||||
message AcknowledgeAlarmReply {
|
||||
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
|
||||
ProtocolStatus protocol_status = 3;
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
optional int32 hresult = 4;
|
||||
|
||||
@@ -327,6 +327,9 @@ impl GatewayClient {
|
||||
/// MXAccess session; the resulting native MxStatus is returned in the
|
||||
/// reply. Acks are idempotent at the MxAccess layer.
|
||||
///
|
||||
/// `operator_user` is required; `operator_password` may be empty. The
|
||||
/// password is scrubbed from any returned error's `Display`/`Debug`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
|
||||
@@ -336,13 +339,19 @@ impl GatewayClient {
|
||||
&self,
|
||||
request: AcknowledgeAlarmRequest,
|
||||
) -> Result<AcknowledgeAlarmReply, Error> {
|
||||
let secret = request.operator_password.clone();
|
||||
let mut client = self.inner.clone();
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
let result = async {
|
||||
let response = client
|
||||
.acknowledge_alarm(self.unary_request(request))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
||||
Ok(reply)
|
||||
}
|
||||
.await;
|
||||
// The operator password must never surface in a returned error.
|
||||
result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()]))
|
||||
}
|
||||
|
||||
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
|
||||
|
||||
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
|
||||
|
||||
impl std::error::Error for MxAccessError {}
|
||||
|
||||
impl Error {
|
||||
/// Scrub exact caller-supplied secrets (for example an operator password)
|
||||
/// from the error's rendered text and any wrapped `tonic::Status`, so that
|
||||
/// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored.
|
||||
#[must_use]
|
||||
pub fn redact_secrets(self, secrets: &[&str]) -> Self {
|
||||
let scrub = |text: &str| {
|
||||
let mut out = text.to_owned();
|
||||
for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
|
||||
out = out.replace(*secret, "<redacted>");
|
||||
}
|
||||
out
|
||||
};
|
||||
let scrub_status = |status: Box<tonic::Status>| {
|
||||
Box::new(tonic::Status::new(status.code(), scrub(status.message())))
|
||||
};
|
||||
match self {
|
||||
Self::Authentication { message, status } => Self::Authentication {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Authorization { message, status } => Self::Authorization {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Timeout { message, status } => Self::Timeout {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Cancelled { message, status } => Self::Cancelled {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Unavailable { message, status } => Self::Unavailable {
|
||||
message: scrub(&message),
|
||||
status: scrub_status(status),
|
||||
},
|
||||
Self::Status(status) => Self::Status(scrub_status(status)),
|
||||
Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message,
|
||||
} => Self::ProtocolStatus {
|
||||
operation,
|
||||
code,
|
||||
message: scrub(&message),
|
||||
},
|
||||
Self::MalformedReply { detail } => Self::MalformedReply {
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::InvalidArgument { name, detail } => Self::InvalidArgument {
|
||||
name,
|
||||
detail: scrub(&detail),
|
||||
},
|
||||
Self::MxAccess(error) => Self::MxAccess(Box::new(
|
||||
error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()),
|
||||
)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tonic::Status> for Error {
|
||||
fn from(status: tonic::Status) -> Self {
|
||||
let message = redact_credentials(status.message());
|
||||
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn redact_secrets_scrubs_status_display_and_debug() {
|
||||
let error: super::Error = tonic::Status::internal("boom pw-secret").into();
|
||||
let error = error.redact_secrets(&["pw-secret", ""]);
|
||||
assert!(!format!("{error:?}").contains("pw-secret"));
|
||||
assert!(!error.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
use tonic::{Code, Status};
|
||||
|
||||
use super::{ensure_mxaccess_success, Error};
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: "investigating".to_owned(),
|
||||
operator_user: "alice".to_owned(),
|
||||
operator_password: String::new(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1036,6 +1038,30 @@ async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_alarm_error_does_not_leak_operator_password() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = client
|
||||
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
||||
client_correlation_id: "corr-pw".to_owned(),
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: String::new(),
|
||||
operator_user: "alice".to_owned(),
|
||||
operator_password: "pw-secret".to_owned(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(!format!("{err:?}").contains("pw-secret"));
|
||||
assert!(!err.to_string().contains("pw-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
|
||||
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to
|
||||
@@ -1073,6 +1099,8 @@ async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_
|
||||
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
|
||||
comment: String::new(),
|
||||
operator_user: String::new(),
|
||||
operator_password: String::new(),
|
||||
operator_domain: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway {
|
||||
&self,
|
||||
request: Request<AcknowledgeAlarmRequest>,
|
||||
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
|
||||
*self.state.last_correlation_id.lock().await =
|
||||
Some(request.into_inner().client_correlation_id);
|
||||
let request = request.into_inner();
|
||||
if request.operator_password == "pw-secret" {
|
||||
return Err(Status::internal("boom pw-secret"));
|
||||
}
|
||||
*self.state.last_correlation_id.lock().await = Some(request.client_correlation_id);
|
||||
Ok(Response::new(AcknowledgeAlarmReply {
|
||||
correlation_id: "corr-1".to_owned(),
|
||||
protocol_status: Some(ok_status("ack ok")),
|
||||
|
||||
@@ -621,8 +621,9 @@ returned to normal but is unacknowledged — i.e., visible in the
|
||||
"current alarms" list because operator hasn't acked it yet) and
|
||||
`UNACK_ALM` (the alarm is currently active and unacknowledged).
|
||||
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
|
||||
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
|
||||
is the method to call.
|
||||
appear when an ack is performed. The alarm-client ack methods
|
||||
(`AlarmAckByGUID` and friends) do not produce them headless; an `AckMsg`
|
||||
write does (`AlarmProbeFindings.md`).
|
||||
|
||||
### `GetStatistics` AV — unrelated quirk
|
||||
|
||||
@@ -660,7 +661,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
||||
events for added/changed/removed records.
|
||||
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
|
||||
fullName)` for client-driven acknowledgements (matches
|
||||
PR A.5's `AlarmAckCommand` payload).
|
||||
PR A.5's `AlarmAckCommand` payload). *Ack step superseded
|
||||
2026-10-19: acks write `AckMsg` as the authenticated operator
|
||||
(see "Acknowledge in subtag mode" and `AlarmProbeFindings.md`).*
|
||||
- Lifecycle teardown: `DeregisterConsumer` +
|
||||
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
|
||||
3. **Conversion layer:** map XML record fields to
|
||||
@@ -701,8 +704,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
||||
`bRetainHiddenAlarms` parameter at all.
|
||||
- Subscription expression format: `\\<machine>\Galaxy!<area>`
|
||||
(literal `Galaxy` provider) for both libraries.
|
||||
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName,
|
||||
node, domain, fullName)` on the v2 surface; ID 5-arg
|
||||
- Native ack surface (not used by the gateway; superseded 2026-10-19 by
|
||||
the authenticated `AckMsg` write): `AlarmAckByGUID(VBGUID guid,
|
||||
comment, oprName, node, domain, fullName)` on the v2 surface; ID 5-arg
|
||||
variant on the legacy `IwwAlarmConsumer` interface.
|
||||
|
||||
These findings retire the open follow-up probes from the
|
||||
@@ -735,25 +739,32 @@ for `SetXmlAlarmQuery`.
|
||||
the canonical `\\<machine>\Galaxy!<area>` form into the XML's
|
||||
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads.
|
||||
|
||||
### 2. Two consumers required: read-side vs. ack-side
|
||||
### 2. Two consumers required: read-side vs. ack-side (historical)
|
||||
|
||||
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
|
||||
> see `AlarmProbeFindings.md`. The worker no longer provisions an ack-only
|
||||
> wnwrap consumer. The text below records why the split existed.
|
||||
|
||||
`SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
|
||||
the same consumer instance**. With SetXml applied, AlarmAckByName
|
||||
returns -55 even with valid name+provider+group+operator. Without
|
||||
SetXml, AlarmAckByName succeeds with rc=0.
|
||||
|
||||
The production consumer therefore provisions **two** wnwrap COM
|
||||
The consumer at the time provisioned **two** wnwrap COM
|
||||
instances:
|
||||
- Primary consumer (`client`): runs full lifecycle including
|
||||
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
|
||||
- Ack-only consumer (`ackClient`): runs Initialize → Register →
|
||||
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**.
|
||||
All `AcknowledgeByName` calls dispatch through this instance.
|
||||
All by-name ack calls dispatched through this instance.
|
||||
|
||||
Both consumers subscribe to the same expression. Disposal cleans up
|
||||
both via a shared `ReleaseConsumerCom` helper.
|
||||
|
||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg
|
||||
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg (historical)
|
||||
|
||||
> Superseded 2026-10-19: the gateway no longer calls `AlarmAckByName`; see
|
||||
> `AlarmProbeFindings.md`.
|
||||
|
||||
`wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
|
||||
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment,
|
||||
@@ -762,8 +773,8 @@ both via a shared `ReleaseConsumerCom` helper.
|
||||
|
||||
The v2 8-arg method returns -55 on this AVEVA build regardless of
|
||||
operator-identity inputs — looks like a stub. The v1 6-arg method
|
||||
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the
|
||||
6-arg overload and discards the proto's `domain` + `full_name` fields.
|
||||
works. `WnWrapAlarmConsumer.AcknowledgeByName` called the
|
||||
6-arg overload and discarded the proto's `domain` + `full_name` fields.
|
||||
|
||||
**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves
|
||||
of the paragraph above are narrower than they read. The -55 tracks the
|
||||
@@ -775,42 +786,34 @@ snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm
|
||||
extension's own `.Acked` attribute stay `False` for 16 s after each
|
||||
call. On this rig the wnwrap ack is accepted and then inert, so a zero
|
||||
return code must not be read as an applied acknowledgement.
|
||||
The proto contract keeps the 8 fields for forward compatibility if
|
||||
AVEVA fixes the v2 method later.
|
||||
|
||||
### 4. `AlarmAckByGUID` is not implemented
|
||||
### 4. `AlarmAckByGUID` is not implemented (historical)
|
||||
|
||||
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
|
||||
> see `AlarmProbeFindings.md`.
|
||||
|
||||
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
|
||||
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
|
||||
build. The reference→GUID lookup that we initially planned to wire
|
||||
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
|
||||
must go through `AlarmAckByName`.
|
||||
through `AlarmAckByGUID` is therefore not viable on wnwrap.
|
||||
|
||||
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
|
||||
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
|
||||
in the codebase for the forward-compat shape, but the gateway-side
|
||||
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
|
||||
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
|
||||
`Provider!Group.Tag` reference.
|
||||
#### Managed GUID ack experiment — did not work (live-tested 2026-10-18, removed 2026-10-19)
|
||||
|
||||
**Opt-in managed GUID ack — does not work on this rig (live-tested
|
||||
2026-10-18).** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` in the
|
||||
gateway's environment (workers inherit it) makes the worker build an
|
||||
`AaManagedAlarmAckConsumer`, which drives
|
||||
`aaAlarmManagedClient.AlarmClient` by reflection (its members are
|
||||
non-public) and is meant to take GUID-keyed acks in place of the
|
||||
wnwrap method. The live test shows it cannot do that as built, so leave
|
||||
the flag off.
|
||||
An opt-in experiment drove `aaAlarmManagedClient.AlarmClient` by reflection
|
||||
(its members are non-public) to take GUID-keyed acks in place of the wnwrap
|
||||
method. The live test showed it could not work, and the experiment and its
|
||||
enabling flag were removed once the `AckMsg` write replaced it. The findings
|
||||
are kept because they explain why the alarm-client route was abandoned.
|
||||
|
||||
The test raised `TestMachine_001.TestAlarm001` with a secured write,
|
||||
read its GUID from the wnwrap XML snapshot (the public API exposes no
|
||||
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
|
||||
with the flag on:
|
||||
with the experiment enabled:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| RPC ack by GUID, flag off (control) | `NotImplementedException`; `.Acked` stays `False` |
|
||||
| RPC ack by GUID, flag on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
|
||||
| RPC ack by GUID, experiment off (control) | `NotImplementedException`; `.Acked` stays `False` |
|
||||
| RPC ack by GUID, experiment on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
|
||||
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
|
||||
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
|
||||
|
||||
@@ -820,7 +823,7 @@ Three separate problems follow:
|
||||
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
|
||||
The dispatcher is built to drop back to the wnwrap ack path on a
|
||||
lifecycle failure (so alarm reads keep working), and it does so
|
||||
silently — which is why the flag-on RPC is indistinguishable from the
|
||||
silently — which is why the experiment-on RPC was indistinguishable from the
|
||||
control.
|
||||
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
|
||||
`SelectByGUID` is "not found", not a security refusal, even with the
|
||||
@@ -833,14 +836,8 @@ Three separate problems follow:
|
||||
strings, so the domain and full name the managed method carries
|
||||
would not arrive through the public RPC even if 1 and 2 were solved.
|
||||
|
||||
A working managed ack would need the client hosted out of process, a way
|
||||
to resolve the alarm to the identifier the managed client uses, and the
|
||||
identity fields plumbed through the gateway. Routing rules the worker
|
||||
already applies stay correct and would carry over: subtag-mode acks
|
||||
(synthetic GUIDs) stay on the subtag consumer, and by-name acks stay on
|
||||
wnwrap because `AlarmClient` has no by-name ack. The skip-gated
|
||||
`AlarmManagedGuidAckLiveSmokeTests` exercises the in-process
|
||||
arrangement and hits problem 1.
|
||||
The route was dropped in favour of the authenticated `AckMsg` write
|
||||
(`AlarmProbeFindings.md`, "Fourth attempt").
|
||||
|
||||
### 5. STA / threading — resolved
|
||||
|
||||
@@ -996,20 +993,24 @@ AVEVA-internal GUID.
|
||||
|
||||
### Acknowledge in subtag mode
|
||||
|
||||
`AlarmDispatcher` routes ack calls by active provider mode:
|
||||
Both provider modes acknowledge through the worker's `AlarmAckWriter`, which
|
||||
calls `AuthenticateUser` as the requesting operator and writes the comment to
|
||||
the ack item over MXAccess on its own `LMXProxyServer`:
|
||||
|
||||
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
|
||||
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
|
||||
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
|
||||
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
|
||||
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
|
||||
write target).
|
||||
- **Alarm-manager mode:** the item is `<TagName>.AckMsg` for Galaxy-provider
|
||||
alarms. An alarm from another provider is not ackable.
|
||||
- **Subtag mode:** `SubtagAlarmConsumer` resolves the watch-list entry's
|
||||
`ack_comment_subtag` and the writer writes that item verbatim. Writing the
|
||||
`AckMsg` subtag performs the acknowledge in AVEVA (`AckMsg` is the confirmed
|
||||
`AlarmExtension` ack-comment write target).
|
||||
|
||||
If the alarm has no writable ack-comment subtag (`AckComment` config key is
|
||||
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
|
||||
returns a failure code that the gateway surfaces as `FailedPrecondition`.
|
||||
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
|
||||
internal dictionary, then calls the same write path.
|
||||
Because the write is authenticated, the Historian records the operator's name,
|
||||
domain and full name, and the writer's MXAccess client name
|
||||
(`MxGateway:Alarms:AckClientName`) as OperatorNode. If the alarm has no ack
|
||||
target (`AckComment` config key is empty, the entry's `ack_comment_subtag` is
|
||||
empty, or the reference or synthetic GUID is unknown), the worker reports it
|
||||
as not found or not ackable and the gateway surfaces `InvalidRequest` with
|
||||
`hresult` -1.
|
||||
|
||||
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
|
||||
observed ones (active/acked/priority). This is required: MXAccess rejects a
|
||||
@@ -1027,7 +1028,7 @@ The subtag path was validated against live MXAccess on the dev rig
|
||||
`….Priority` → `500` (Int32), `….AckMsg` → string — confirming the field
|
||||
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
|
||||
with **no** intermediate alarm-condition segment.
|
||||
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
|
||||
- The `AckMsg` write returned `0` once the ack-comment subtag
|
||||
was advised — confirming the ack-by-comment-write mechanism end to end.
|
||||
|
||||
### Fidelity limitations
|
||||
|
||||
@@ -24,13 +24,15 @@ the blocker rather than rediscovering it.
|
||||
|---|---|
|
||||
| GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) |
|
||||
| GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID |
|
||||
| GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy |
|
||||
| GUID stable across `UNACK → ACK` | Blocker resolved 2026-10-19 — the rig is driven to `ACK_ALM` by an `AckMsg` write, and the `MxSecurityOperate` classification was not the cause (see "Fourth attempt"); GUID survival across that transition has not been recorded yet |
|
||||
| Acknowledge an alarm headless | Answered 2026-10-19 — **an `AckMsg` write as the authenticated operator**; every alarm-client ack is inert (see "Fourth attempt") |
|
||||
| `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** |
|
||||
|
||||
The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The
|
||||
2026-08-18 run cleared that blocker with `AuthenticateUser` + `WriteSecured` and answered
|
||||
two of the three questions; the acknowledge leg is now blocked on something narrower and
|
||||
different, described in "Second attempt" and diagnosed in "Third attempt".
|
||||
different, described in "Second attempt" and diagnosed in "Third attempt"
|
||||
(resolved in the Fourth attempt, 2026-10-19).
|
||||
|
||||
## First attempt (2026-08-17): plain `Write`
|
||||
|
||||
@@ -420,3 +422,96 @@ The second attempt's inventory, unchanged since: the three
|
||||
snapshot, since nothing can acknowledge them away. `SnapshotActiveAlarms` counts only
|
||||
`UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on
|
||||
the next `alarmmgr` restart.
|
||||
|
||||
## Fourth attempt (2026-10-19): authenticated AckMsg write
|
||||
|
||||
The third attempt left the acknowledge leg blocked. This run found a route that
|
||||
acknowledges and identifies the operator, and ruled out the alarm-client routes.
|
||||
It drove `TestMachine_001.TestAlarm001`, raised with
|
||||
`mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`.
|
||||
|
||||
### Writing `AckMsg` acknowledges
|
||||
|
||||
Writing the comment to `TestMachine_001.TestAlarm001.AckMsg` over MXAccess
|
||||
acknowledges the alarm: `.Acked` becomes `True` and the wnwrap record moves to
|
||||
`STATE=ACK_ALM` with an `ACK_COMMENT`. `AckMsg` is `MxSecurityFreeAccess` even
|
||||
though the alarm UDA itself is `MxSecurityOperate`, so no Galaxy change is
|
||||
needed.
|
||||
|
||||
What the Historian records, read from `Runtime.dbo.v_AlarmEventHistory2`:
|
||||
|
||||
| Write | Operator | DomainName | UserFullName | OperatorNode | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| Unauthenticated `Write` | `NULL` | `NULL` | `NULL` | `NULL` | the comment |
|
||||
| `AuthenticateUser` + `Write(value, userId)` | `Administrator` | `ArchestrA` | `Administrator` | `mxa` (the MXAccess `Register()` client name) | the comment |
|
||||
|
||||
The wnwrap XML also gains `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`,
|
||||
`ACK_OPERATOR_NODE` and `ACK_COMMENT` on the acknowledged record; the worker's
|
||||
snapshot parser reads them for Acknowledge transitions. An unauthenticated write
|
||||
therefore acknowledges but leaves no operator in the Historian, which is why the
|
||||
gateway requires `operator_user` and authenticates it.
|
||||
|
||||
Another actor's `AckMsg` writes on the same day (authenticated as another
|
||||
operator account) logged that account as `Operator`, `Domain="."` and that machine's name as the node,
|
||||
which matches the client-name rule. By this date unauthenticated writes to
|
||||
`MxSecurityOperate` attributes were also accepted Galaxy-wide on the rig, where
|
||||
August refused them with 1008.
|
||||
|
||||
### Alarm-client acks are inert in every variant
|
||||
|
||||
All of these returned `rc=0` or `-1` and changed neither the snapshot, `.Acked`
|
||||
nor the Historian:
|
||||
|
||||
- wnwrap `AlarmAckByName` and `AlarmAckByGUID` (`E_NOTIMPL`) on the ack-only and
|
||||
reader consumers.
|
||||
- wnwrap `IwwAlarmConsumer2` on a sole consumer with `SetXmlAlarmQuery` applied
|
||||
(list populated, `GetAlarm(0)` returns the row): `AlarmAck` with ids
|
||||
0/1/2/3/-1/65536, and `Select(0)` + `AlarmAckSelected`, with operators
|
||||
`TestOperator`, `Administrator` and a third account. `SelectByGUID` is `E_NOTIMPL`.
|
||||
- The same calls with `TestAlarm001` temporarily reclassified to
|
||||
`MxSecurityFreeAccess` (template and instance, restored afterwards), so the
|
||||
security classification is not the cause.
|
||||
- Managed `aaAlarmManagedClient`: `AlarmAckByGUID` and `SelectByGUID` return
|
||||
`-1` (the client's list never fills headless, even with a real `hWnd` and a
|
||||
message pump); `AckSelectedTagAlarms_Ex` returns `0` even for a
|
||||
`NOSUCHPROVIDER` provider and writes no Historian row. The managed and wnwrap
|
||||
consumers cannot share a process (`RegisterConsumer` returns `-65`).
|
||||
- A 20 s continuous message pump after `AlarmAck`, and registering as `InTouch`,
|
||||
`WindowViewer` or `Wonderware InTouch`.
|
||||
|
||||
Static reverse engineering matched: the five-string COM acks forward domain and
|
||||
full name with an empty password, and InTouch's `AlarmViewer.ocx` also sends an
|
||||
empty password. The user-request call is synchronous request-to-return-code with
|
||||
no completion channel, so the drop happens provider-side. Hooking the user
|
||||
request in a live WindowViewer that acknowledges successfully and diffing it
|
||||
against the headless probe is the one definitive step not taken.
|
||||
|
||||
### Consequence
|
||||
|
||||
The gateway acknowledges by authenticating the operator and writing `AckMsg`
|
||||
(`docs/DesignDecisions.md`, "Alarm acknowledgement writes AckMsg as the
|
||||
authenticated operator"). The managed ack consumer and the second wnwrap ack
|
||||
consumer were removed.
|
||||
|
||||
### Gateway-level verification
|
||||
|
||||
The same result holds end to end through the gateway. With the feature build deployed on
|
||||
the windev rig and `TestAlarm001` raised, an `AcknowledgeAlarm` RPC for
|
||||
`Galaxy!TestArea.TestMachine_001.TestAlarm001` with `operator_user=Administrator` and an
|
||||
empty password returned `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. `.Acked` turned True,
|
||||
`.AckMsg` held the comment, and the Historian logged the `ACK_ALM` row with
|
||||
`Operator=Administrator`, `DomainName=ArchestrA`, `UserFullName=Administrator`,
|
||||
`OperatorNode=MxGateway` (the default `AckClientName`), and `Description` = the comment.
|
||||
|
||||
A wrong password, or a user Galaxy security does not know, makes `AuthenticateUser`
|
||||
throw `ArgumentException` (`E_INVALIDARG`, 0x80070057) rather than a `COMException`.
|
||||
That is why the writer classifies interop failures by HRESULT across exception types.
|
||||
Such an ack returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked,
|
||||
the Historian gains no ack row, and the password string does not appear in the gateway
|
||||
log, `stdout.log`, or `stderr.log`. A non-Galaxy provider reference returns
|
||||
`PROTOCOL_STATUS_CODE_INVALID_REQUEST` with `hresult` -1, and an empty `operator_user`
|
||||
returns gRPC `InvalidArgument`.
|
||||
|
||||
The GUID arm of the ack command was not driven live. The public alarm RPCs expose only
|
||||
the full reference, so clients reach the by-name arm; worker unit tests cover the GUID
|
||||
arm.
|
||||
|
||||
@@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request)
|
||||
}
|
||||
```
|
||||
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. The trade-off is that an `invoke:write` key can tell `AuthenticationFailed` (an `MXACCESS_FAILURE` whose diagnostic reports operator authentication failure) apart from every other outcome, which makes `AcknowledgeAlarm` a Galaxy-password oracle for that key — whereas the `AuthenticateUser` command itself requires `invoke:secure`. What bounds it: the caller must already hold a valid, admin-issued, revocable `invoke:write` key, and every acknowledgement is serialized through the single monitor worker session's STA, so guesses run one at a time. The gateway does **not** throttle failed operator authentications: the `ApiKeyFailureLimiter` counts only failed API-key verifications, and the ASP.NET Core rate limiter covers only the dashboard `/login` endpoint. Any lockout after repeated bad passwords comes from the Galaxy security configuration, not from the gateway. Grant `invoke:write` only to clients trusted with that exposure. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
|
||||
|
||||
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
|
||||
|
||||
|
||||
@@ -49,6 +49,17 @@ worker prepares the replacement on its STA before swapping it into the existing
|
||||
failover consumer and returns a normal `MxCommandReply`; callers must treat only
|
||||
`protocol_status = OK` as acknowledgement of the new list.
|
||||
|
||||
`AcknowledgeAlarmRequest` carries the operator's identity: `operator_user`
|
||||
(required), `operator_password` (field 6) and `operator_domain` (field 7). The
|
||||
gateway copies them onto `AcknowledgeAlarmCommand` (`operator_password` = 7) and
|
||||
`AcknowledgeAlarmByNameCommand` (`operator_password` = 9); the worker
|
||||
authenticates the operator and writes `AckMsg` as that user. The fields are
|
||||
additive, so `GatewayProtocolVersion` stays `3`. `operator_password` is a
|
||||
credential and follows the same never-log rule as
|
||||
`AuthenticateUserCommand.verify_user_password`: it must not reach logs, metric
|
||||
labels, `DiagnosticMessage`, `ProtocolStatus.Message`, or exception text. A
|
||||
not-found or not-ackable alarm is reported with `hresult` -1.
|
||||
|
||||
The command model also includes bulk write/read command kinds:
|
||||
`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and
|
||||
`ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway`
|
||||
|
||||
+50
-8
@@ -703,14 +703,15 @@ latch in subtag mode.
|
||||
|
||||
### Acknowledge via ack-comment write
|
||||
|
||||
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
|
||||
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
|
||||
performs the native ack in AVEVA. This differs from alarmmgr mode, where
|
||||
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
|
||||
subtag name is empty by default; configuring it is required for ack to work in
|
||||
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
|
||||
config block exists precisely so names are not guessed without validation
|
||||
against the live MXAccess attribute set.
|
||||
Both provider modes acknowledge by writing the operator comment to `AckMsg` as
|
||||
the authenticated operator (see "Alarm acknowledgement writes AckMsg as the
|
||||
authenticated operator" below). In alarm-manager mode the target is
|
||||
`<TagName>.AckMsg`; in subtag mode it is the watch-list entry's ack-comment
|
||||
subtag (`Fallback:Subtags:AckComment`, default `AckMsg`). The write performs
|
||||
the ack in AVEVA. An entry with an empty ack-comment subtag is not ackable and
|
||||
the RPC reports `InvalidRequest`. The exact AVEVA subtag names are not
|
||||
hard-coded — the `Subtags` config block exists so names are not guessed
|
||||
without validation against the live MXAccess attribute set.
|
||||
|
||||
### Related documentation
|
||||
|
||||
@@ -784,6 +785,47 @@ The following items were previously listed here and have since shipped:
|
||||
`MxGateway:Sessions:MaxEventSubscribersPerSession`.
|
||||
See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md).
|
||||
|
||||
## Alarm acknowledgement writes AckMsg as the authenticated operator
|
||||
|
||||
**Decision (2026-10-19).** `AcknowledgeAlarm` authenticates the requesting
|
||||
operator with `AuthenticateUser` (`operator_user`, `operator_password`,
|
||||
optional `operator_domain`) and writes the comment to `<TagName>.AckMsg` over
|
||||
MXAccess as that user, on a dedicated `LMXProxyServer` registered as
|
||||
`MxGateway:Alarms:AckClientName`. Both provider modes use this path.
|
||||
|
||||
**Why.** Live tests (2026-10-18/19) showed every alarm-client ack is inert on a
|
||||
headless consumer: wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/
|
||||
`AlarmAckSelected`, and the managed `aaAlarmManagedClient` GUID ack, return
|
||||
rc=0 or -1 and change nothing, regardless of security classification,
|
||||
operator name or consumer name. An MXAccess write to `AckMsg` does acknowledge
|
||||
the alarm, and when the write is made under an authenticated user the AVEVA
|
||||
runtime historizes that identity (`Operator`, `DomainName`, `UserFullName`),
|
||||
with the MXAccess client name as `OperatorNode`. The requirement is that the
|
||||
real operator appears in the Historian, as with InTouch, so a shared gateway
|
||||
service account was rejected. Operators have Galaxy accounts, so the
|
||||
per-request credential is workable. See
|
||||
[Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- `operator_user` is required; `operator_password` may be empty.
|
||||
- `operator_password` is a credential. It is forwarded to the worker over the
|
||||
pipe but never logged: `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are
|
||||
credential-bearing commands and any property named like `password` is
|
||||
redacted.
|
||||
- `Applied` and `Unconfirmed` (write sent, no `OnWriteComplete` in the bounded
|
||||
wait) both report `OK`; clients that need certainty watch for the transition.
|
||||
- The managed ack consumer, the WNAL native ack consumer, the second wnwrap
|
||||
ack-only consumer and the opt-in managed-ack environment flag are removed.
|
||||
|
||||
**Upgrade note.** This is a breaking change for callers. They must now send
|
||||
`operator_user` (required; empty is rejected with `InvalidArgument`) and the
|
||||
operator's Galaxy password; a caller that sends no password for a
|
||||
password-protected account gets `MXACCESS_FAILURE` from `AuthenticateUser`
|
||||
instead of an acknowledgement. OtOpcUa's `GalaxyDriver` alarm acknowledger is a
|
||||
known caller that must be updated to forward the operator's credentials before
|
||||
the gateway is upgraded.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and
|
||||
|
||||
## Goals
|
||||
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, `AcknowledgeAlarm` (its `operator_password`), or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
|
||||
A second goal is parity-test diagnosability. Because MXAccess sessions, workers, correlation ids, and command methods are the units of comparison, every log entry produced inside a request scope must carry those identifiers without each call site having to format them.
|
||||
|
||||
@@ -70,7 +70,9 @@ private static readonly HashSet<string> SensitiveCommandMethods = new(StringComp
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
"WriteSecured2",
|
||||
"AcknowledgeAlarm",
|
||||
"AcknowledgeAlarmByName"
|
||||
};
|
||||
|
||||
public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
@@ -80,7 +82,7 @@ public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
}
|
||||
```
|
||||
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below.
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below. `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are in the set because their commands carry the operator's `operator_password`. Independently of the method set, the name-based seam redacts any property whose name contains `password`, so a credential field added to a payload later is redacted without a code change.
|
||||
|
||||
### API key redaction
|
||||
|
||||
|
||||
@@ -442,17 +442,18 @@ behavior.
|
||||
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor re-resolves and, on material change, hot-swaps the subtag fallback watch-list, then reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot. The acknowledged watch-list fingerprint advances only after an OK worker reply. Floored at 5 seconds. |
|
||||
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be between `100` and `3600000` (one hour): every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path, while a value above an hour stops being a cadence and silently disables alarm polling. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or out-of-range value leaves the worker on the 500 ms default rather than failing the session. |
|
||||
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be between `64` and `65536` — the worker is a 32-bit process that materializes each reply as one BSTR plus a full `XmlDocument`, so an unbounded cap faults the STA with an out-of-memory rather than merely slowing it. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning to its stderr (identifiers and counts only). **Remediation when you see that warning: raise this value** so the steady-state active-alarm count fits inside one fetch. A galaxy permanently above the cap holds stale entries in the snapshot until a sub-cap poll, and loses clear→re-raise cycles that happen entirely out of window (see `docs/DesignDecisions.md`). Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or out-of-range value leaves the worker on the 1024 default. |
|
||||
| `MxGateway:Alarms:AckClientName` | `MxGateway` | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via `MXGATEWAY_ALARM_ACK_CLIENT_NAME`. |
|
||||
|
||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||
|
||||
The worker also reads `MXGATEWAY_USE_MANAGED_ALARM_ACK` (inherited from the
|
||||
gateway process). Setting it to `1` is meant to route GUID-keyed acknowledges
|
||||
through AVEVA's managed alarm client, but a live test (2026-10-18) showed the
|
||||
path cannot work as built: the managed client fails to register alongside the
|
||||
worker's wnwrap reader and the worker silently falls back to wnwrap. Leave it
|
||||
unset. It has no `MxGateway:*` option. See
|
||||
[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack").
|
||||
`AckClientName` is the MXAccess client name the alarm worker's ack writer
|
||||
registers its dedicated `LMXProxyServer` with. The Historian records it as the
|
||||
OperatorNode of each acknowledgement, so it identifies the gateway as the
|
||||
acknowledging node. The gateway passes it to each worker through the
|
||||
`MXGATEWAY_ALARM_ACK_CLIENT_NAME` environment variable. Startup validation
|
||||
rejects an empty value, a value over 64 characters, or one containing control
|
||||
characters.
|
||||
|
||||
### Alarm fallback options
|
||||
|
||||
@@ -471,7 +472,7 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback.
|
||||
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the merged watch-list (case-insensitive). The exclude is applied after the Repository-derived rows and the explicit `IncludeAttributes` entries are combined, so an exclude that matches an explicit include suppresses it too — excludes win. Ignored when `UseGalaxyRepository` is `false`. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag, as the authenticated operator, performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the alarm is not ackable and the RPC returns `InvalidRequest`. |
|
||||
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
|
||||
|
||||
Validation rules:
|
||||
|
||||
+24
-2
@@ -96,9 +96,30 @@ Selected control commands have additional public behavior beyond their protobuf
|
||||
|
||||
### `AcknowledgeAlarm`
|
||||
|
||||
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` and `operator_user` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
|
||||
|
||||
An `OK` response means the alarm provider accepted the acknowledgement, not that it applied it: the gateway forwards the ack and reports what the provider returned, in keeping with the parity rule. On galaxies whose alarmed attributes carry a non-free-access security classification the by-name ack is accepted and inert — the underlying `AlarmAckByName` conveys an operator name rather than an authenticated identity, observed on the probe rig with the mechanism inferred rather than confirmed — so a client that needs to confirm an acknowledgement should watch for the resulting transition on `StreamAlarms` instead of treating `OK` as proof. See [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
Request fields that identify the acknowledging operator:
|
||||
|
||||
- `operator_user` (required): the Galaxy account name. An empty value is rejected with `InvalidArgument` so the real operator is always recorded.
|
||||
- `operator_password` (field 6): the operator's Galaxy password. May be empty because Galaxy accounts can have empty passwords. It is a credential and is never logged.
|
||||
- `operator_domain` (field 7): optional. When set, the worker authenticates `domain\user`; when empty it authenticates `user`.
|
||||
|
||||
The worker calls `AuthenticateUser` as the operator, then writes `operator_comment` to `<TagName>.AckMsg` (subtag mode: the configured ack-comment subtag) on a dedicated MXAccess server registered as `MxGateway:Alarms:AckClientName`, and waits a bounded time for `OnWriteComplete`. The AVEVA runtime treats that write as the acknowledgement, and the Historian (`Runtime.dbo.v_AlarmEventHistory2`) records the operator name, domain and full name from the authenticated account, with the ack client name as `OperatorNode`. The alarm-client ack calls (`AlarmAckByName`, `AlarmAckByGUID`) are not used because a headless consumer's acks are accepted and dropped; see [Alarm Probe Findings](./AlarmProbeFindings.md).
|
||||
|
||||
| Worker outcome | Response status | `hresult` |
|
||||
|---|---|---|
|
||||
| Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` |
|
||||
| Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` |
|
||||
| `AuthenticateUser` raised an MXAccess error (bad user or wrong password surfaces as `E_INVALIDARG` `0x80070057`, raised by the interop as `ArgumentException`; `COMException` and `UnauthorizedAccessException` are classified the same way) | `MXACCESS_FAILURE` | the exception's HRESULT |
|
||||
| `AuthenticateUser` returned user id `0` | `MXACCESS_FAILURE` | `-1` |
|
||||
| `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT |
|
||||
| `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT |
|
||||
| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` |
|
||||
| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, or the monitor's worker session having no active alarm subscription — `InvalidOperationException`) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT |
|
||||
|
||||
`reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md).
|
||||
|
||||
**Upgrade note.** Callers must now send `operator_user` (required) and the operator's Galaxy password in `operator_password`. A caller that sends no `operator_user` gets `InvalidArgument`; a caller that sends no password for a password-protected Galaxy account gets `MXACCESS_FAILURE` from `AuthenticateUser` instead of an acknowledgement. Every existing caller must be updated before the gateway is upgraded — known callers include OtOpcUa's `GalaxyDriver` alarm acknowledger, which must supply the operator's credentials.
|
||||
|
||||
### `StreamAlarms`
|
||||
|
||||
@@ -217,6 +238,7 @@ receive this event directly.
|
||||
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
|
||||
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
|
||||
| `AcknowledgeAlarm` | `operator_user` must be non-empty. Validated inline in the handler. | `InvalidArgument` |
|
||||
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
|
||||
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+22
-18
@@ -140,8 +140,9 @@ one gateway-owned worker session subscribes the configured AVEVA alarm
|
||||
provider, caches the active-alarm set (reconciled periodically against the
|
||||
worker's snapshot), and fans it out to every client through the session-less
|
||||
`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
|
||||
then streams live transitions. `AcknowledgeAlarm` is session-less and routes
|
||||
through the monitor. Clients never open a worker session to see alarms, and
|
||||
then streams live transitions. `AcknowledgeAlarm` is session-less, routes
|
||||
through the monitor, and carries the operator's credentials (see
|
||||
"Acknowledge" below). Clients never open a worker session to see alarms, and
|
||||
alarm monitoring is independent of client lifecycle; the monitor re-opens its
|
||||
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
|
||||
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
||||
@@ -221,20 +222,22 @@ forwards only events the worker emits and never synthesizes transitions
|
||||
itself. The synthesis rules are documented in
|
||||
`docs/AlarmClientDiscovery.md`.
|
||||
|
||||
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
|
||||
comment to the alarm attribute's ack-comment subtag. The write performs the
|
||||
ack. If the attribute has no writable ack-comment subtag configured, the RPC
|
||||
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
|
||||
used as before.
|
||||
|
||||
**Acceptance is not application:** the ack is forwarded to the provider, and a
|
||||
successful return means the provider accepted the call, not that the
|
||||
acknowledgement was applied. On galaxies whose alarmed attributes carry a
|
||||
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the
|
||||
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated
|
||||
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack
|
||||
by the resulting transition, never by the return code. See
|
||||
`docs/AlarmProbeFindings.md`.
|
||||
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
|
||||
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
|
||||
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
|
||||
operator (`operator_user`, `operator_password`, optional `operator_domain`).
|
||||
The AVEVA runtime treats that write as the acknowledgement and historizes it
|
||||
with the authenticated account's name, domain and full name; the Historian's
|
||||
OperatorNode is the ack writer's MXAccess client name
|
||||
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
|
||||
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
|
||||
`MxaccessFailure` with the status detail, and no completion as success with a
|
||||
diagnostic noting the write was sent but unconfirmed. An unknown alarm or one
|
||||
with no ack target (non-Galaxy provider, no ack-comment subtag) is reported as
|
||||
`InvalidRequest` with `hresult` -1. Alarm-client ack calls
|
||||
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
|
||||
used: live tests showed a headless consumer's acks are accepted and silently
|
||||
dropped (see `docs/AlarmProbeFindings.md`).
|
||||
|
||||
**Degraded state visibility:** every subtag-mode transition carries
|
||||
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
|
||||
@@ -1028,8 +1031,9 @@ Internal worker IPC:
|
||||
- gateway validates worker executable path and version,
|
||||
- no secrets in command line when avoidable.
|
||||
|
||||
Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured`
|
||||
must not log passwords or raw credential values.
|
||||
Credential-sensitive commands such as `AuthenticateUser`, `WriteSecured` and
|
||||
`AcknowledgeAlarm` (its `operator_password`) must not log passwords or raw
|
||||
credential values.
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
|
||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
||||
}
|
||||
|
||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||
// recorded atomically with the ack transition in the alarm-history log.
|
||||
// The reply's hresult / native_status surfaces AVEVA's
|
||||
// AlarmAckByGUID return code.
|
||||
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
||||
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
||||
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
||||
// AuthenticateUser as the operator, so the alarm history records the real
|
||||
// operator. The reply's hresult / native_status is 0 when the write was
|
||||
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
||||
// the OnWriteComplete status detail.
|
||||
message AcknowledgeAlarmCommand {
|
||||
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
||||
string alarm_guid = 1;
|
||||
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
|
||||
string operator_node = 4;
|
||||
string operator_domain = 5;
|
||||
string operator_full_name = 6;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 7;
|
||||
}
|
||||
|
||||
// Snapshot the currently-active alarm set. Optional filter prefix scopes
|
||||
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
|
||||
|
||||
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
|
||||
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
|
||||
// be resolved to a GUID directly. The worker invokes
|
||||
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
|
||||
// history path as AlarmAckByGUID.
|
||||
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
|
||||
message AcknowledgeAlarmByNameCommand {
|
||||
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
|
||||
// may contain dots; the gateway-side parser splits on the first dot
|
||||
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
|
||||
string operator_node = 6;
|
||||
string operator_domain = 7;
|
||||
string operator_full_name = 8;
|
||||
// Operator credential used for AuthenticateUser before the AckMsg write.
|
||||
// Implementations must keep this field out of logs, metrics labels, command
|
||||
// lines, and diagnostics.
|
||||
string operator_password = 9;
|
||||
}
|
||||
|
||||
message UnsubscribeBulkCommand {
|
||||
@@ -725,11 +734,11 @@ message DrainEventsReply {
|
||||
// Reply payload for AcknowledgeAlarmCommand AND
|
||||
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
|
||||
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
|
||||
// dedicated by-name reply case. Surfaces AVEVA's native ack return
|
||||
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
|
||||
// by-name arm); 0 means success. The MxCommandReply's hresult field
|
||||
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
|
||||
// AckMsg write was applied or sent but unconfirmed; otherwise the
|
||||
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
|
||||
// carries the same value and is preferred for protocol consumers —
|
||||
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
|
||||
// this payload exists so GatewayAlarmMonitor
|
||||
// can echo native_status into AcknowledgeAlarmReply.hresult without
|
||||
// unpacking the outer envelope.
|
||||
message AcknowledgeAlarmReplyPayload {
|
||||
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
|
||||
// Operator principal performing the acknowledgement. The lmxopcua side
|
||||
// resolves this from the OPC UA session prior to invoking the RPC.
|
||||
string operator_user = 5;
|
||||
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
||||
// operator_user with it before writing the acknowledgement so the alarm
|
||||
// history records the real operator. May be empty for accounts without a
|
||||
// password. Credential-sensitive: implementations must keep this field out
|
||||
// of logs, metrics labels, command lines, and diagnostics.
|
||||
string operator_password = 6;
|
||||
// Optional domain for OS-authenticated galaxies. When set the gateway
|
||||
// authenticates as "<operator_domain>\<operator_user>".
|
||||
string operator_domain = 7;
|
||||
}
|
||||
|
||||
message AcknowledgeAlarmReply {
|
||||
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
|
||||
ProtocolStatus protocol_status = 3;
|
||||
// Native ack return code echoed from the worker. The worker carries the
|
||||
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
|
||||
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
|
||||
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
|
||||
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
|
||||
// ack-outcome field for the public RPC. Absent only when the worker reply
|
||||
// omitted the value entirely (a protocol violation).
|
||||
optional int32 hresult = 4;
|
||||
|
||||
@@ -22,4 +22,10 @@ public static class WorkerLaunchEnvironment
|
||||
|
||||
/// <summary>Maximum alarm records requested in one current-alarm fetch.</summary>
|
||||
public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess client name the worker's alarm-ack writer registers with. The
|
||||
/// Historian records it as the acknowledgement's OperatorNode.
|
||||
/// </summary>
|
||||
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
|
||||
}
|
||||
|
||||
@@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
{
|
||||
parseError = null;
|
||||
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
||||
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
string comment = request.Comment ?? string.Empty;
|
||||
string operatorUser = request.OperatorUser ?? string.Empty;
|
||||
string operatorPassword = request.OperatorPassword ?? string.Empty;
|
||||
string operatorDomain = request.OperatorDomain ?? string.Empty;
|
||||
|
||||
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||
{
|
||||
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorDomain = operatorDomain,
|
||||
OperatorPassword = operatorPassword,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorDomain = operatorDomain,
|
||||
OperatorPassword = operatorPassword,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
|
||||
/// </summary>
|
||||
public int MaxAlarmsPerFetch { get; init; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// MXAccess client name the alarm worker's ack writer registers with. The
|
||||
/// AVEVA Historian records it as each acknowledgement's OperatorNode.
|
||||
/// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
|
||||
/// </summary>
|
||||
public string AckClientName { get; init; } = "MxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||
|
||||
@@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
$"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.AckClientName)
|
||||
|| options.AckClientName.Length > 64
|
||||
|| options.AckClientName.Any(char.IsControl))
|
||||
{
|
||||
builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters.");
|
||||
}
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
"WriteSecured2",
|
||||
// Carry operator_password.
|
||||
"AcknowledgeAlarm",
|
||||
"AcknowledgeAlarmByName"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
|
||||
}
|
||||
|
||||
RedactCommandValue(properties);
|
||||
|
||||
foreach (string key in properties.Keys.ToList())
|
||||
{
|
||||
if (key.Contains("password", StringComparison.OrdinalIgnoreCase) && properties[key] is not null)
|
||||
{
|
||||
properties[key] = GatewayLogRedactor.RedactedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -197,6 +197,11 @@ public sealed class MxAccessGatewayService(
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.OperatorUser))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
|
||||
}
|
||||
|
||||
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -223,6 +223,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
||||
startInfo.Environment[WorkerLaunchEnvironment.MaxAlarmsPerFetch] =
|
||||
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;
|
||||
|
||||
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
||||
|
||||
return startInfo;
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"DefaultArea": "",
|
||||
"ReconcileIntervalSeconds": 30,
|
||||
"PollIntervalMilliseconds": 500,
|
||||
"MaxAlarmsPerFetch": 1024
|
||||
"MaxAlarmsPerFetch": 1024,
|
||||
"AckClientName": "MxGateway"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
||||
|
||||
public sealed class GatewayAlarmMonitorAcknowledgeTests
|
||||
{
|
||||
/// <summary>A GUID-referenced ack carries the operator identity and password to the worker command.</summary>
|
||||
[Fact]
|
||||
public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials()
|
||||
{
|
||||
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
|
||||
Comment = "checked",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "pw-secret",
|
||||
}, out string? error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind);
|
||||
Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser);
|
||||
Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain);
|
||||
Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword);
|
||||
}
|
||||
|
||||
/// <summary>A name-referenced ack carries the operator identity and password to the by-name command.</summary>
|
||||
[Fact]
|
||||
public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials()
|
||||
{
|
||||
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
|
||||
{
|
||||
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "pw-secret",
|
||||
}, out _);
|
||||
|
||||
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind);
|
||||
Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName);
|
||||
Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser);
|
||||
Assert.Equal("CORP", command.AcknowledgeAlarmByNameCommand.OperatorDomain);
|
||||
Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,45 @@ public sealed class GatewayOptionsValidatorTests
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
/// <summary>Verifies AckClientName defaults to "MxGateway" and passes validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultAckClientName()
|
||||
{
|
||||
Assert.Equal("MxGateway", new AlarmsOptions().AckClientName);
|
||||
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator()
|
||||
.Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions()));
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an empty, over-long, or control-character AckClientName fails validation.</summary>
|
||||
/// <param name="name">Client name under test.</param>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("bad\nname")]
|
||||
public void Validate_Fails_WhenAckClientNameInvalid(string name)
|
||||
{
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator()
|
||||
.Validate(null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = name }));
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Alarms:AckClientName", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a 65-character AckClientName fails and a 64-character one passes.</summary>
|
||||
[Fact]
|
||||
public void Validate_AckClientNameLengthBoundary()
|
||||
{
|
||||
GatewayOptionsValidator validator = new();
|
||||
Assert.False(validator.Validate(
|
||||
null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 65) })).Succeeded);
|
||||
Assert.True(validator.Validate(
|
||||
null, CloneWithAlarms(ValidOptions(), new AlarmsOptions { AckClientName = new string('a', 64) })).Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
|
||||
|
||||
@@ -310,11 +310,47 @@ public sealed class ProtobufContractRoundTripTests
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
Comment = "shift handover",
|
||||
OperatorUser = "operator2",
|
||||
OperatorPassword = "s3cret",
|
||||
OperatorDomain = "CORP",
|
||||
};
|
||||
|
||||
var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal("s3cret", parsed.OperatorPassword);
|
||||
Assert.Equal("CORP", parsed.OperatorDomain);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the worker ack commands round-trip the operator password.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeAlarmCommands_RoundTripOperatorPassword()
|
||||
{
|
||||
MxCommand byGuid = new()
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
||||
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
||||
{
|
||||
AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
|
||||
OperatorUser = "alice",
|
||||
OperatorDomain = "CORP",
|
||||
OperatorPassword = "s3cret",
|
||||
},
|
||||
};
|
||||
MxCommand byName = new()
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
||||
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
||||
{
|
||||
AlarmName = "TestMachine_001.TestAlarm001",
|
||||
ProviderName = "Galaxy",
|
||||
GroupName = "TestArea",
|
||||
OperatorUser = "alice",
|
||||
OperatorPassword = "s3cret",
|
||||
},
|
||||
};
|
||||
|
||||
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword);
|
||||
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics.</summary>
|
||||
|
||||
@@ -84,4 +84,23 @@ public sealed class GatewayLogRedactorSeamTests
|
||||
|
||||
Assert.False(props.ContainsKey("CommandValue"));
|
||||
}
|
||||
|
||||
/// <summary>Any property whose name contains "password" is masked regardless of casing.</summary>
|
||||
[Fact]
|
||||
public void Redact_PasswordNamedProperty_IsRedacted()
|
||||
{
|
||||
GatewayLogRedactorSeam redactor = new();
|
||||
Dictionary<string, object?> props = new()
|
||||
{
|
||||
["OperatorPassword"] = "pw-secret",
|
||||
["verify_user_PASSWORD"] = "pw-secret",
|
||||
["OperatorUser"] = "alice",
|
||||
};
|
||||
|
||||
redactor.Redact(props);
|
||||
|
||||
Assert.Equal("[redacted]", props["OperatorPassword"]);
|
||||
Assert.Equal("[redacted]", props["verify_user_PASSWORD"]);
|
||||
Assert.Equal("alice", props["OperatorUser"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ public sealed class GatewayLogRedactorTests
|
||||
[InlineData("AuthenticateUser")]
|
||||
[InlineData("WriteSecured")]
|
||||
[InlineData("WriteSecured2")]
|
||||
[InlineData("AcknowledgeAlarm")]
|
||||
[InlineData("AcknowledgeAlarmByName")]
|
||||
public void IsCredentialBearingCommand_IdentifiesSensitiveMxAccessCommands(string commandMethod)
|
||||
{
|
||||
Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod));
|
||||
|
||||
@@ -389,6 +389,22 @@ public sealed class MxAccessGatewayServiceTests
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeAlarm rejects an empty operator_user.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
|
||||
{
|
||||
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.AcknowledgeAlarm(
|
||||
new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" },
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||
Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeAlarm delegates a valid request to the alarm service.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed class WorkerProcessLauncherTests
|
||||
WorkerLaunchEnvironment.EventQueueCapacity,
|
||||
WorkerLaunchEnvironment.AlarmPollIntervalMilliseconds,
|
||||
WorkerLaunchEnvironment.MaxAlarmsPerFetch,
|
||||
WorkerLaunchEnvironment.AlarmAckClientName,
|
||||
];
|
||||
|
||||
/// <summary>Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable.</summary>
|
||||
@@ -75,6 +76,10 @@ public sealed class WorkerProcessLauncherTests
|
||||
"1024",
|
||||
processFactory.LastStartInfo.Environment[
|
||||
WorkerLaunchEnvironment.MaxAlarmsPerFetch]);
|
||||
Assert.Equal(
|
||||
"MxGateway",
|
||||
processFactory.LastStartInfo.Environment[
|
||||
WorkerLaunchEnvironment.AlarmAckClientName]);
|
||||
Assert.All(
|
||||
RequiredLaunchEnvironmentVariables,
|
||||
variable => Assert.True(
|
||||
@@ -86,6 +91,28 @@ public sealed class WorkerProcessLauncherTests
|
||||
Assert.Equal(0, metrics.GetSnapshot().WorkersRunning);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a configured AckClientName reaches the worker environment.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task LaunchAsync_WithConfiguredAckClientName_ExportsItToTheWorkerEnvironment()
|
||||
{
|
||||
using TestDirectory directory = TestDirectory.Create();
|
||||
string executablePath = directory.CreateWorkerExecutable(machine: 0x014c);
|
||||
FakeWorkerProcessFactory processFactory = new(new FakeWorkerProcess(processId: 1234));
|
||||
WorkerProcessLauncher launcher = CreateLauncher(
|
||||
executablePath,
|
||||
processFactory,
|
||||
new SucceedingStartupProbe(),
|
||||
ackClientName: "Plant-Ack");
|
||||
|
||||
using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest());
|
||||
|
||||
Assert.NotNull(processFactory.LastStartInfo);
|
||||
Assert.Equal(
|
||||
"Plant-Ack",
|
||||
processFactory.LastStartInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a configured <see cref="WorkerOptions.EventQueueCapacity"/> — not the shipped
|
||||
/// default — is what reaches the worker, so the option is deployable without a worker rebuild.
|
||||
@@ -265,10 +292,12 @@ public sealed class WorkerProcessLauncherTests
|
||||
int startupTimeoutSeconds = 30,
|
||||
int startupProbeRetryAttempts = 3,
|
||||
int startupProbeRetryDelayMilliseconds = 250,
|
||||
int eventQueueCapacity = 10000)
|
||||
int eventQueueCapacity = 10000,
|
||||
string ackClientName = "MxGateway")
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Alarms = new AlarmsOptions { AckClientName = ackClientName },
|
||||
Worker = new WorkerOptions
|
||||
{
|
||||
ExecutablePath = executablePath,
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
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<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
|
||||
new RepeatedField<MxStatusProxy> { 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 };
|
||||
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");
|
||||
|
||||
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 Acknowledge_WhenAdviseThrows_RejectsCleansUpAndReAddsNextTime()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, AdviseException = new COMException("x", unchecked((int)0x80004005)) };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
server.AdviseException = null;
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, first.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80004005), first.NativeStatus);
|
||||
Assert.DoesNotContain("pw-secret", first.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Contains("RemoveItem", server.Operations);
|
||||
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime()
|
||||
{
|
||||
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");
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
server.WriteException = new COMException("x", unchecked((int)0x80004005));
|
||||
|
||||
AlarmAckResult failed = writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
server.WriteException = null;
|
||||
writer.Acknowledge("T.A.AckMsg", "three", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, failed.Outcome);
|
||||
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenWriteDisconnected_TearsDownServer()
|
||||
{
|
||||
// The test seam has no COM factory, so re-registration itself is not
|
||||
// exercised here; this verifies the teardown that makes it possible.
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new COMException("x", unchecked((int)0x80010108)) };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
|
||||
Assert.Contains("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserDisconnected_TearsDownAndRejects()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new COMException("gone pw-secret", unchecked((int)0x80010108)),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
|
||||
Assert.Contains("AuthenticateUser", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Contains("0x80010108", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Contains("Unregister", server.Operations);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("AddItem", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenAuthenticateUserThrowsArgumentException_ReturnsAuthenticationFailed()
|
||||
{
|
||||
// Live MXAccess raises E_INVALIDARG for a bad user or wrong password, which the
|
||||
// interop surfaces as ArgumentException rather than COMException.
|
||||
RecordingAckServer server = new RecordingAckServer
|
||||
{
|
||||
AuthenticateResult = 5,
|
||||
AuthenticateException = new ArgumentException("Value does not fall within the expected range. pw-secret"),
|
||||
};
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
||||
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenWriteThrowsArgumentException_RejectsAndDropsItem()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new ArgumentException("bad") };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
||||
|
||||
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
||||
Assert.Equal(unchecked((int)0x80070057), result.NativeStatus);
|
||||
Assert.Contains("Write", result.Diagnostic, StringComparison.Ordinal);
|
||||
Assert.Equal(new[] { 100 }, server.RemovedItemHandles);
|
||||
Assert.DoesNotContain("Unregister", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_PastCacheCap_EvictsLeastRecentlyUsedItem()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(1), "MxGateway", maxCachedItems: 2);
|
||||
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
||||
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
||||
writer.Acknowledge("C.X.AckMsg", "3", Alice()); // handle 102, evicts A (100)
|
||||
|
||||
Assert.Equal(new[] { 100 }, server.RemovedItemHandles);
|
||||
Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_ReusedItem_IsMostRecentlyUsedForEviction()
|
||||
{
|
||||
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", maxCachedItems: 2);
|
||||
|
||||
writer.Acknowledge("A.X.AckMsg", "1", Alice()); // handle 100
|
||||
writer.Acknowledge("B.X.AckMsg", "2", Alice()); // handle 101
|
||||
writer.Acknowledge("A.X.AckMsg", "3", Alice()); // reuse 100, now most recent
|
||||
writer.Acknowledge("C.X.AckMsg", "4", Alice()); // handle 102, evicts B (101)
|
||||
|
||||
Assert.Equal(new[] { 101 }, server.RemovedItemHandles);
|
||||
Assert.Equal(3, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
Assert.Contains("UnAdvise", server.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_AfterUnconfirmed_UsesFreshHandleSoStaleCompletionIsNotCredited()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
||||
|
||||
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice()); // handle 100, no completion
|
||||
// The first write's late OnWriteComplete (handle 100) lands while the
|
||||
// second write is in flight; the second write's own completion never comes.
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, 100, Rows(MxStatusCategory.Ok, 0));
|
||||
AlarmAckResult second = writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.Unconfirmed, first.Outcome);
|
||||
Assert.Equal(AlarmAckOutcome.Unconfirmed, second.Outcome);
|
||||
Assert.Contains(100, server.RemovedItemHandles);
|
||||
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
||||
Assert.Equal(new[] { 100, 101 }, server.WrittenItemHandles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_AfterConfirmed_ReusesHandle()
|
||||
{
|
||||
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");
|
||||
|
||||
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
||||
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
||||
|
||||
Assert.Equal(new[] { 100, 100 }, server.WrittenItemHandles);
|
||||
Assert.Empty(server.RemovedItemHandles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne()
|
||||
{
|
||||
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
||||
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
||||
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 0));
|
||||
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(-1, result.NativeStatus);
|
||||
}
|
||||
|
||||
[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<string> Operations { get; } = new();
|
||||
|
||||
public int AuthenticateResult { get; set; }
|
||||
|
||||
public Exception? AuthenticateException { get; set; }
|
||||
|
||||
public Exception? AdviseException { get; set; }
|
||||
|
||||
public Exception? WriteException { get; set; }
|
||||
|
||||
public string? LastPassword { get; private set; }
|
||||
|
||||
public Action<int, int>? OnWrite { get; set; }
|
||||
|
||||
public List<int> RemovedItemHandles { get; } = new();
|
||||
|
||||
public List<int> WrittenItemHandles { get; } = new();
|
||||
|
||||
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");
|
||||
RemovedItemHandles.Add(itemHandle);
|
||||
}
|
||||
|
||||
public void Advise(int serverHandle, int itemHandle)
|
||||
{
|
||||
Operations.Add("Advise");
|
||||
if (AdviseException is not null)
|
||||
{
|
||||
throw AdviseException;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
WrittenItemHandles.Add(itemHandle);
|
||||
if (WriteException is not null)
|
||||
{
|
||||
throw WriteException;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
|
||||
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
|
||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary>
|
||||
/// <summary>Verifies that a rejected write carries its native status and diagnostic.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every ack outcome maps to its protocol status, carries the native
|
||||
/// status, and never echoes the operator password.
|
||||
/// </summary>
|
||||
/// <param name="outcome">The writer outcome.</param>
|
||||
/// <param name="expected">The expected protocol status.</param>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>The by-name ack maps outcomes identically and never echoes the operator password.</summary>
|
||||
/// <param name="outcome">The writer outcome.</param>
|
||||
/// <param name="expected">The expected protocol status.</param>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>The writer's OnWriteComplete statuses are copied onto the reply.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
|
||||
@@ -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
|
||||
/// <summary>Gets the last acknowledge alarm GUID.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
|
||||
/// <summary>Gets the last acknowledge operator name.</summary>
|
||||
public string? LastAckOperatorName { get; private set; }
|
||||
/// <summary>Gets the last acknowledge comment.</summary>
|
||||
public string? LastComment { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the value returned by acknowledge.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets the last acknowledge operator.</summary>
|
||||
public AlarmAckOperator? LastOperator { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
|
||||
public bool AcknowledgeThrow { get; set; }
|
||||
/// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
|
||||
public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||
|
||||
/// <summary>Gets or sets an exception both acknowledge methods throw.</summary>
|
||||
public Exception? AcknowledgeException { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the query result snapshots.</summary>
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
|
||||
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid, string comment, string operatorUser,
|
||||
string operatorNode, string operatorDomain, string operatorFullName)
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last acknowledge by name tuple.</summary>
|
||||
|
||||
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
|
||||
Assert.False(handler.IsSubscribed);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
|
||||
/// <summary>Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a null operator is rejected.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_NullOperator_Throws()
|
||||
{
|
||||
AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
|
||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ack writer is created once, on first acknowledgement, survives an
|
||||
/// unsubscribe/resubscribe cycle, and is disposed with the handler.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without a COM factory or writer factory, subscribing still works and
|
||||
/// the acknowledgement fails with a diagnosable InvalidOperationException.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(
|
||||
() => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
|
||||
Assert.Contains("IMxAccessComObjectFactory", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
|
||||
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
|
||||
() => new FakeConsumer());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
|
||||
@@ -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<InvalidOperationException>(
|
||||
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
||||
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
|
||||
() => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
|
||||
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
|
||||
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
|
||||
}
|
||||
|
||||
private static AlarmCommandHandler NewHandlerWithWriter(Func<IMxAccessAlarmConsumer> consumerFactory, IAlarmAckWriter writer) =>
|
||||
new AlarmCommandHandler(
|
||||
new MxAccessEventQueue(),
|
||||
consumerFactory,
|
||||
threadAffinityCheck: null,
|
||||
comFactory: null,
|
||||
standbyFactory: null,
|
||||
ackWriterFactory: () => writer);
|
||||
|
||||
/// <summary>Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).</summary>
|
||||
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
|
||||
|
||||
/// <summary>Gets the last subscription request.</summary>
|
||||
public string? LastSubscription { get; private set; }
|
||||
/// <summary>Gets the last acknowledged alarm GUID.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
/// <summary>Gets the last acknowledged operator name.</summary>
|
||||
public string? LastAckOperatorName { get; private set; }
|
||||
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
|
||||
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
|
||||
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
|
||||
public Guid LastResolvedGuid { get; private set; }
|
||||
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
|
||||
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
|
||||
/// <summary>Gets or sets the snapshot result to return.</summary>
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
||||
Array.Empty<MxAlarmSnapshotRecord>();
|
||||
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,38 @@ public sealed class AlarmDispatcherTests
|
||||
{
|
||||
private const string SessionId = "session-001";
|
||||
|
||||
/// <summary>Verifies an Acknowledge transition is attributed to the ACK_* operator and comment.</summary>
|
||||
[Fact]
|
||||
public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment()
|
||||
{
|
||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
|
||||
|
||||
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||
{
|
||||
PreviousState = MxAlarmStateKind.UnackAlm,
|
||||
Record = new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = Guid.NewGuid(),
|
||||
ProviderName = "Galaxy",
|
||||
Group = "TestArea",
|
||||
TagName = "TestMachine_001.TestAlarm001",
|
||||
State = MxAlarmStateKind.AckAlm,
|
||||
AlarmComment = "Test alarm #1",
|
||||
AckOperatorName = "alice",
|
||||
AckComment = "checked",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.True(queue.TryDequeue(out WorkerEvent? evt));
|
||||
OnAlarmTransitionEvent body = evt!.Event.OnAlarmTransition;
|
||||
Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
|
||||
Assert.Equal("alice", body.OperatorUser);
|
||||
Assert.Equal("checked", body.OperatorComment);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm transitions land in the queue with correctly mapped fields.</summary>
|
||||
[Fact]
|
||||
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
|
||||
@@ -24,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
|
||||
@@ -110,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
|
||||
{
|
||||
@@ -145,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
|
||||
{
|
||||
@@ -173,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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary>
|
||||
/// <summary>Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a managed ack client takes GUID acks with full operator identity.</summary>
|
||||
/// <summary>Verifies an unresolved target short-circuits to its failure without calling the writer.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a failed managed-client lifecycle neither fails the alarm
|
||||
/// subscription nor leaves acks routed to the broken client.
|
||||
/// </summary>
|
||||
/// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a NotAckable by-name target short-circuits without calling the writer.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the dispatcher disposes the managed ack client.</summary>
|
||||
/// <summary>Verifies the dispatcher does not dispose the writer: the handler owns it.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
|
||||
/// <summary>Verifies the constructor rejects a null ack writer.</summary>
|
||||
[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<ArgumentNullException>(() => 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!));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
|
||||
@@ -359,7 +376,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
@@ -415,7 +433,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||
Assert.Equal(2, snapshots.Count);
|
||||
@@ -450,7 +469,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
@@ -480,7 +500,8 @@ public sealed class AlarmDispatcherTests
|
||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||
consumer,
|
||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||
SessionId);
|
||||
SessionId,
|
||||
new FakeAckWriter());
|
||||
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||
|
||||
@@ -510,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();
|
||||
|
||||
@@ -537,20 +558,12 @@ public sealed class AlarmDispatcherTests
|
||||
|
||||
/// <summary>Gets the last subscription reference.</summary>
|
||||
public string? LastSubscription { get; private set; }
|
||||
/// <summary>Gets the GUID from the last acknowledge call.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
/// <summary>Gets the comment from the last acknowledge call.</summary>
|
||||
public string? LastAckComment { get; private set; }
|
||||
/// <summary>Gets the operator name from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorName { get; private set; }
|
||||
/// <summary>Gets the operator node from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorNode { get; private set; }
|
||||
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorDomain { get; private set; }
|
||||
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorFullName { get; private set; }
|
||||
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
|
||||
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
|
||||
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
|
||||
public Guid LastResolvedGuid { get; private set; }
|
||||
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
|
||||
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
|
||||
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
||||
Array.Empty<MxAlarmSnapshotRecord>();
|
||||
@@ -575,37 +588,19 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
|
||||
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
@@ -632,49 +627,22 @@ public sealed class AlarmDispatcherTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeManagedAckClient : IManagedAlarmAckClient
|
||||
private sealed class FakeAckWriter : IAlarmAckWriter
|
||||
{
|
||||
/// <summary>Gets the last subscription expression.</summary>
|
||||
public string? LastSubscription { get; private set; }
|
||||
/// <summary>Gets the GUID from the last acknowledge call.</summary>
|
||||
public Guid LastAckGuid { get; private set; }
|
||||
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorDomain { get; private set; }
|
||||
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
|
||||
public string? LastAckOperatorFullName { get; private set; }
|
||||
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
|
||||
public int AcknowledgeReturn { get; set; }
|
||||
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
|
||||
public bool ThrowOnSubscribe { get; set; }
|
||||
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
|
||||
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; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorName,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
{
|
||||
LastAckGuid = alarmGuid;
|
||||
LastAckOperatorDomain = operatorDomain;
|
||||
LastAckOperatorFullName = operatorFullName;
|
||||
return AcknowledgeReturn;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
}
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 11;
|
||||
public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("primary.guid.AckMsg");
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11;
|
||||
public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("primary.name.AckMsg");
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
||||
@@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests
|
||||
public void PollOnce() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 22;
|
||||
public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("standby.guid.AckMsg");
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22;
|
||||
public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("standby.name.AckMsg");
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
|
||||
@@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests
|
||||
Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount);
|
||||
}
|
||||
|
||||
/// <summary>Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active.</summary>
|
||||
/// <summary>Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active.</summary>
|
||||
[Fact]
|
||||
public void Acknowledge_DelegatesToActiveChild()
|
||||
public void ResolveAckTarget_DelegatesToActiveChild()
|
||||
{
|
||||
FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false };
|
||||
StubStandby standby = new StubStandby();
|
||||
@@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests
|
||||
|
||||
sut.Subscribe(@"\\HOST\Galaxy!Area");
|
||||
|
||||
// Active = Primary → primary's sentinel value (11).
|
||||
Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full"));
|
||||
Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full"));
|
||||
// Active = Primary → primary's sentinel targets.
|
||||
Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
|
||||
Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
|
||||
|
||||
// Force a failover by failing the primary past threshold.
|
||||
primary.ThrowOnPoll = true;
|
||||
sut.PollOnce(); // threshold=1 → switch to Standby
|
||||
Assert.Equal(AlarmProviderMode.Subtag, sut.Mode);
|
||||
|
||||
// Active = Standby → standby's sentinel value (22).
|
||||
Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full"));
|
||||
Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full"));
|
||||
// Active = Standby → standby's sentinel targets.
|
||||
Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
|
||||
Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when
|
||||
/// it was not previously advised and writes with user id 0.
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises and
|
||||
/// removes every advised handle and unregisters the server exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write_AddsItemWhenUnknownAndWrites()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Write("Tank1.Alarm.AckComment", "acknowledged");
|
||||
|
||||
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
|
||||
Assert.Single(server.Writes);
|
||||
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
|
||||
Assert.Equal(FakeServerHandle, write.ServerHandle);
|
||||
Assert.Equal("acknowledged", write.Value);
|
||||
Assert.Equal(0, write.UserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
|
||||
/// item handle (no duplicate AddItem) when the address was already
|
||||
/// advised.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write_ReusesHandleForAdvisedAddress()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Advise(new[] { "Tank1.Alarm.Subtag" });
|
||||
int adviseAddCount = server.AddedItems.Count;
|
||||
|
||||
source.Write("Tank1.Alarm.Subtag", true);
|
||||
|
||||
// No second AddItem for the already-advised address.
|
||||
Assert.Equal(adviseAddCount, server.AddedItems.Count);
|
||||
Assert.Single(server.Writes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises only the
|
||||
/// handles that were actually advised — a write-only item (added by
|
||||
/// <see cref="LmxSubtagAlarmSource.Write"/> but never advised) is removed
|
||||
/// but not unadvised — and unregisters the server exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce()
|
||||
public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce()
|
||||
{
|
||||
var server = new RecordingMxAccessServer();
|
||||
var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
|
||||
|
||||
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
|
||||
// A write-only subtag: added by Write, never advised.
|
||||
source.Write("Tank1.Alarm.AckComment", "acknowledged");
|
||||
|
||||
int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag");
|
||||
int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag");
|
||||
int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment");
|
||||
|
||||
source.Dispose();
|
||||
|
||||
// Only the two advised handles are unadvised — never the write-only one.
|
||||
Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles);
|
||||
Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles);
|
||||
// Every added item (advised + write-only) is removed.
|
||||
Assert.Equal(
|
||||
new[] { advised1, advised2, writeOnly }.OrderBy(h => h),
|
||||
new[] { advised1, advised2 }.OrderBy(h => h),
|
||||
server.RemovedItemHandles.OrderBy(h => h));
|
||||
Assert.Equal(1, server.UnregisterCount);
|
||||
Assert.Empty(server.Writes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag
|
||||
/// fallback advises the observable subtags, synthesizes degraded
|
||||
/// transitions with stable synthetic GUIDs, routes acknowledgments to the
|
||||
/// transitions with stable synthetic GUIDs, resolves acknowledgments to the
|
||||
/// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/>
|
||||
/// stands in for the live MXAccess subtag source so this needs no AVEVA
|
||||
/// install.
|
||||
@@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests
|
||||
return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
|
||||
}
|
||||
|
||||
/// <summary>Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item).</summary>
|
||||
/// <summary>
|
||||
/// Verifies Subscribe advises the observable active, acked and priority
|
||||
/// subtags but not the ack-comment subtag: the ack is written by
|
||||
/// <see cref="AlarmAckWriter"/>, which adds and advises it on its own server.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Subscribe_AdvisesAllSubtagsIncludingAckComment()
|
||||
public void Subscribe_AdvisesObservableSubtagsButNotAckComment()
|
||||
{
|
||||
FakeSource source = new FakeSource();
|
||||
using SubtagAlarmConsumer consumer = BuildConsumer(source);
|
||||
@@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests
|
||||
Assert.Contains(ActiveSubtag, source.Advised);
|
||||
Assert.Contains(AckedSubtag, source.Advised);
|
||||
Assert.Contains(PrioritySubtag, source.Advised);
|
||||
Assert.Contains(AckCommentSubtag, source.Advised);
|
||||
Assert.DoesNotContain(AckCommentSubtag, source.Advised);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
|
||||
@@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests
|
||||
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary>
|
||||
/// <summary>Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
|
||||
public void ResolveAckTargetByName_ReturnsAckCommentSubtag()
|
||||
{
|
||||
FakeSource source = new FakeSource();
|
||||
using SubtagAlarmConsumer consumer = BuildConsumer(source);
|
||||
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
|
||||
|
||||
int rc = consumer.AcknowledgeByName(
|
||||
alarmName: "Tank01.Level.HiHi",
|
||||
providerName: "Galaxy",
|
||||
groupName: "TestArea",
|
||||
ackComment: "operator ack",
|
||||
ackOperatorName: "alice",
|
||||
ackOperatorNode: "WS01",
|
||||
ackOperatorDomain: "CORP",
|
||||
ackOperatorFullName: "Alice Smith");
|
||||
AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea");
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.NotNull(source.LastWrite);
|
||||
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
|
||||
Assert.Equal("operator ack", source.LastWrite!.Value.Value);
|
||||
Assert.True(target.Found);
|
||||
Assert.Equal(AckCommentSubtag, target.ItemAddress);
|
||||
}
|
||||
|
||||
/// <summary>Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.</summary>
|
||||
/// <summary>Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero()
|
||||
public void ResolveAckTargetByName_UnknownAlarm_IsNotFound()
|
||||
{
|
||||
FakeSource source = new FakeSource();
|
||||
using SubtagAlarmConsumer consumer = BuildConsumer(source);
|
||||
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
|
||||
|
||||
int rc = consumer.AcknowledgeByName(
|
||||
alarmName: "DoesNotExist.NoSuchAlarm",
|
||||
providerName: "Galaxy",
|
||||
groupName: "TestArea",
|
||||
ackComment: "operator ack",
|
||||
ackOperatorName: "alice",
|
||||
ackOperatorNode: "WS01",
|
||||
ackOperatorDomain: "CORP",
|
||||
ackOperatorFullName: "Alice Smith");
|
||||
AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea");
|
||||
|
||||
Assert.NotEqual(0, rc);
|
||||
Assert.Null(source.LastWrite);
|
||||
Assert.False(target.Found);
|
||||
Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID.</summary>
|
||||
[Fact]
|
||||
public void ResolveAckTarget_EmptyAckCommentSubtag_IsNotAckable()
|
||||
{
|
||||
AlarmSubtagTarget noAck = BuildTarget();
|
||||
noAck.AckCommentSubtag = string.Empty;
|
||||
FakeSource source = new FakeSource();
|
||||
using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, new[] { noAck });
|
||||
|
||||
Assert.Equal(
|
||||
AlarmAckOutcome.NotAckable,
|
||||
consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea").Failure);
|
||||
Assert.Equal(
|
||||
AlarmAckOutcome.NotAckable,
|
||||
consumer.ResolveAckTarget(SyntheticAlarmGuid.ForReference(Reference)).Failure);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
|
||||
@@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi),
|
||||
/// AcknowledgeByName routes each ack to its own ack-comment subtag and never
|
||||
/// ResolveAckTargetByName resolves each to its own ack-comment subtag and never
|
||||
/// conflates the shorter name with the longer one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByName_PrefixNameDoesNotFalseMatch()
|
||||
public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch()
|
||||
{
|
||||
const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi";
|
||||
const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi";
|
||||
@@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests
|
||||
source, new[] { targetHi, targetHiHi });
|
||||
consumer.Subscribe(@"\\HOST\Galaxy!Area");
|
||||
|
||||
// Ack the shorter name — must write to the shorter target's subtag only.
|
||||
int rcHi = consumer.AcknowledgeByName(
|
||||
alarmName: "Tank01.Level.Hi",
|
||||
providerName: "Galaxy",
|
||||
groupName: "Area",
|
||||
ackComment: "ack hi",
|
||||
ackOperatorName: "op", ackOperatorNode: "WS01",
|
||||
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
|
||||
|
||||
Assert.Equal(0, rcHi);
|
||||
Assert.NotNull(source.LastWrite);
|
||||
Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address);
|
||||
Assert.Equal("ack hi", source.LastWrite.Value.Value);
|
||||
|
||||
// Ack the longer name — must write to the longer target's subtag.
|
||||
int rcHiHi = consumer.AcknowledgeByName(
|
||||
alarmName: "Tank01.Level.HiHi",
|
||||
providerName: "Galaxy",
|
||||
groupName: "Area",
|
||||
ackComment: "ack hihi",
|
||||
ackOperatorName: "op", ackOperatorNode: "WS01",
|
||||
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
|
||||
|
||||
Assert.Equal(0, rcHiHi);
|
||||
Assert.NotNull(source.LastWrite);
|
||||
Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address);
|
||||
Assert.Equal("ack hihi", source.LastWrite.Value.Value);
|
||||
Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress);
|
||||
Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress);
|
||||
Assert.Equal(
|
||||
AlarmAckOutcome.AlarmNotFound,
|
||||
consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from
|
||||
/// the alarm's full reference) to the correct target and writes the comment
|
||||
/// to that target's ack-comment subtag.
|
||||
/// Verifies ResolveAckTarget resolves the synthetic GUID (computed from
|
||||
/// the alarm's full reference) to that target's ack-comment subtag.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag()
|
||||
public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag()
|
||||
{
|
||||
FakeSource source = new FakeSource();
|
||||
using SubtagAlarmConsumer consumer = BuildConsumer(source);
|
||||
@@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests
|
||||
Guid syntheticGuid = emitted!.Record.AlarmGuid;
|
||||
Assert.NotEqual(Guid.Empty, syntheticGuid);
|
||||
|
||||
int rc = consumer.AcknowledgeByGuid(
|
||||
alarmGuid: syntheticGuid,
|
||||
ackComment: "guid ack",
|
||||
ackOperatorName: "op",
|
||||
ackOperatorNode: "WS01",
|
||||
ackOperatorDomain: "CORP",
|
||||
ackOperatorFullName: "Operator");
|
||||
AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid);
|
||||
|
||||
Assert.Equal(0, rc);
|
||||
Assert.NotNull(source.LastWrite);
|
||||
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
|
||||
Assert.Equal("guid ack", source.LastWrite.Value.Value);
|
||||
Assert.True(target.Found);
|
||||
Assert.Equal(AckCommentSubtag, target.ItemAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies AcknowledgeByGuid returns non-zero and performs no write when
|
||||
/// the supplied GUID is not known to the consumer.
|
||||
/// </summary>
|
||||
/// <summary>Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer.</summary>
|
||||
[Fact]
|
||||
public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero()
|
||||
public void ResolveAckTarget_UnknownGuid_IsNotFound()
|
||||
{
|
||||
FakeSource source = new FakeSource();
|
||||
using SubtagAlarmConsumer consumer = BuildConsumer(source);
|
||||
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
|
||||
|
||||
int rc = consumer.AcknowledgeByGuid(
|
||||
alarmGuid: Guid.NewGuid(),
|
||||
ackComment: "should not write",
|
||||
ackOperatorName: "op",
|
||||
ackOperatorNode: "WS01",
|
||||
ackOperatorDomain: "CORP",
|
||||
ackOperatorFullName: "Operator");
|
||||
|
||||
Assert.NotEqual(0, rc);
|
||||
Assert.Null(source.LastWrite);
|
||||
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
|
||||
}
|
||||
|
||||
private sealed class FakeSource : ISubtagAlarmSource
|
||||
@@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests
|
||||
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
|
||||
public List<string> Advised { get; } = new List<string>();
|
||||
|
||||
/// <summary>Gets the most recent (address, value) pair passed to <see cref="Write"/>.</summary>
|
||||
public (string Address, object? Value)? LastWrite { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Advise(IReadOnlyCollection<string> itemAddresses)
|
||||
{
|
||||
Advised.AddRange(itemAddresses);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(string itemAddress, object? value)
|
||||
{
|
||||
LastWrite = (itemAddress, value);
|
||||
}
|
||||
|
||||
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
|
||||
/// <param name="address">The subtag address whose value changed.</param>
|
||||
/// <param name="value">The new value.</param>
|
||||
|
||||
@@ -801,4 +801,81 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
ProviderName = "Galaxy",
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSnapshotXml_WithAckElements_PopulatesAckFields()
|
||||
{
|
||||
const string xml =
|
||||
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\"><ALARM><GUID>2D8A9BE88FE04B0F8D996BB4D56620C7</GUID>" +
|
||||
"<DATE>2026/10/19</DATE><TIME>18:2:53.812</TIME><GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
|
||||
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE><PROVIDER_NAME>Galaxy</PROVIDER_NAME><GROUP>TestArea</GROUP>" +
|
||||
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME><TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
|
||||
"<PRIORITY>500</PRIORITY><STATE>ACK_ALM</STATE><OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
|
||||
"<ACK_DOMAIN_NAME>ArchestrA</ACK_DOMAIN_NAME><ACK_OPERATOR_NODE>mxa</ACK_OPERATOR_NODE>" +
|
||||
"<ACK_OPERATOR_NAME>Administrator</ACK_OPERATOR_NAME><ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>" +
|
||||
"<ACK_COMMENT>mgack-authwrite 180714</ACK_COMMENT></ALARM></ALARM_RECORDS>";
|
||||
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
||||
|
||||
MxAlarmSnapshotRecord record = Assert.Single(records.Values);
|
||||
Assert.Equal("Administrator", record.AckOperatorName);
|
||||
Assert.Equal("ArchestrA", record.AckOperatorDomain);
|
||||
Assert.Equal("mxa", record.AckOperatorNode);
|
||||
Assert.Equal("mgack-authwrite 180714", record.AckComment);
|
||||
Assert.Equal("Test alarm #1", record.AlarmComment);
|
||||
}
|
||||
|
||||
/// <summary>A Galaxy-provider record resolves to its <c><tag>.AckMsg</c> item; an unknown GUID is not found.</summary>
|
||||
[Fact]
|
||||
public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
|
||||
});
|
||||
|
||||
AlarmAckTarget target = consumer.ResolveAckTarget(guid);
|
||||
|
||||
Assert.True(target.Found);
|
||||
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
|
||||
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
|
||||
}
|
||||
|
||||
/// <summary>A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable.</summary>
|
||||
[Fact]
|
||||
public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
|
||||
});
|
||||
|
||||
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
|
||||
}
|
||||
|
||||
/// <summary>By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item.</summary>
|
||||
[Fact]
|
||||
public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
|
||||
{
|
||||
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();
|
||||
|
||||
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
|
||||
consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
|
||||
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
|
||||
}
|
||||
|
||||
private static WnWrapAlarmConsumer CreateConsumerWithSnapshot(params MxAlarmSnapshotRecord[] records)
|
||||
{
|
||||
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(1024);
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||
foreach (MxAlarmSnapshotRecord record in records)
|
||||
{
|
||||
next[record.AlarmGuid] = record;
|
||||
}
|
||||
|
||||
consumer.FoldFetch(next, truncated: false, out _);
|
||||
return consumer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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}");
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install:
|
||||
// LmxSubtagAlarmSource (own LMXProxyServerClass) ->
|
||||
// SubtagAlarmConsumer (state machine + AcknowledgeByName write) ->
|
||||
// SubtagAlarmConsumer (state machine + ResolveAckTargetByName) + AlarmAckWriter (AckMsg write) ->
|
||||
// synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid)
|
||||
//
|
||||
// FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA
|
||||
@@ -49,8 +49,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
|
||||
/// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy
|
||||
/// alarm subtags using the confirmed AVEVA <c>AlarmExtension</c> field
|
||||
/// names (InAlarm/Acked/AckMsg/Priority), and that
|
||||
/// <see cref="SubtagAlarmConsumer.AcknowledgeByName"/> writes the
|
||||
/// ack-comment subtag (AckMsg) successfully.
|
||||
/// <see cref="SubtagAlarmConsumer.ResolveAckTargetByName"/> resolves the
|
||||
/// ack-comment subtag (AckMsg) that <see cref="AlarmAckWriter"/> then
|
||||
/// writes successfully.
|
||||
///
|
||||
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with an alarm being
|
||||
/// driven (flip script or a manual operator/IDE toggle of the alarm
|
||||
@@ -119,7 +120,7 @@ public sealed class AlarmSubtagLiveSmokeTests
|
||||
/// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear
|
||||
/// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms
|
||||
/// the Degraded flag and synthetic GUID are stamped, then
|
||||
/// AcknowledgeByName and verifies the ack-comment write returns 0.
|
||||
/// acknowledges through the resolved ack-comment subtag and verifies the write succeeds.
|
||||
/// </summary>
|
||||
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")]
|
||||
public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges()
|
||||
@@ -257,17 +258,9 @@ public sealed class AlarmSubtagLiveSmokeTests
|
||||
Log("InAlarm subtag delivered a value: " + inAlarmResolved);
|
||||
|
||||
// Decisive write: AckMsg. Returns 0 only if the address resolves and is writable.
|
||||
Log("AcknowledgeByName (writes AckMsg subtag) ...");
|
||||
int rc = consumer.AcknowledgeByName(
|
||||
alarmName: AlarmTagName,
|
||||
providerName: Provider,
|
||||
groupName: Group,
|
||||
ackComment: "subtag-resolution-probe",
|
||||
ackOperatorName: Environment.UserName,
|
||||
ackOperatorNode: Environment.MachineName,
|
||||
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
|
||||
ackOperatorFullName: Environment.UserName);
|
||||
Log(string.Format("AcknowledgeByName(AckMsg write) -> rc={0}", rc));
|
||||
Log("Acknowledge (writes AckMsg subtag via AlarmAckWriter) ...");
|
||||
int rc = AckViaWriter(consumer, "subtag-resolution-probe").NativeStatus;
|
||||
Log(string.Format("Ack (AckMsg write) -> rc={0}", rc));
|
||||
|
||||
DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < settle)
|
||||
@@ -392,21 +385,13 @@ public sealed class AlarmSubtagLiveSmokeTests
|
||||
Assert.True(foundInSnapshot,
|
||||
string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference));
|
||||
|
||||
// 3. AcknowledgeByName — writes the ack-comment subtag.
|
||||
// 3. Resolve the ack-comment subtag and write it as the operator.
|
||||
// The dispatcher derives (alarmName, provider, group) via the same
|
||||
// TryParseReference logic as AlarmsLiveSmokeTests.
|
||||
Log("Calling AcknowledgeByName ...");
|
||||
int rc = consumer.AcknowledgeByName(
|
||||
alarmName: AlarmTagName,
|
||||
providerName: Provider,
|
||||
groupName: Group,
|
||||
ackComment: "subtag-fallback-smoke ack",
|
||||
ackOperatorName: Environment.UserName,
|
||||
ackOperatorNode: Environment.MachineName,
|
||||
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
|
||||
ackOperatorFullName: Environment.UserName);
|
||||
Log("Acknowledging via AlarmAckWriter ...");
|
||||
int rc = AckViaWriter(consumer, "subtag-fallback-smoke ack").NativeStatus;
|
||||
|
||||
Log(string.Format("AcknowledgeByName -> rc={0}", rc));
|
||||
Log(string.Format("Ack -> rc={0}", rc));
|
||||
Assert.Equal(0, rc);
|
||||
|
||||
// 4. Wait for a Clear or Acknowledge transition to confirm the state
|
||||
@@ -501,6 +486,29 @@ public sealed class AlarmSubtagLiveSmokeTests
|
||||
/// Mirrors the pump pattern documented in
|
||||
/// <c>docs/MxAccessWorkerInstanceDesign.md</c>.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Resolves the probe alarm's ack item through the consumer and writes it
|
||||
/// with <see cref="AlarmAckWriter"/> as the operator named by
|
||||
/// <c>MXGATEWAY_PROBE_ACK_USER</c>/<c>MXGATEWAY_PROBE_ACK_PASSWORD</c>
|
||||
/// (default: current user, empty password).
|
||||
/// </summary>
|
||||
private static AlarmAckResult AckViaWriter(SubtagAlarmConsumer consumer, string comment)
|
||||
{
|
||||
AlarmAckTarget target = consumer.ResolveAckTargetByName(AlarmTagName, Provider, Group);
|
||||
if (!target.Found)
|
||||
{
|
||||
return AlarmAckResult.FromTarget(target);
|
||||
}
|
||||
|
||||
using AlarmAckWriter writer = new AlarmAckWriter(
|
||||
new MxAccessComObjectFactory(), PumpMessages, TimeSpan.FromSeconds(5), AlarmAckWriter.DefaultClientName);
|
||||
return writer.Acknowledge(target.ItemAddress, comment, new AlarmAckOperator
|
||||
{
|
||||
UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
|
||||
Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
private static void PumpMessages()
|
||||
{
|
||||
NativeMethods.MSG msg;
|
||||
|
||||
@@ -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 <tag>.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
|
||||
|
||||
@@ -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, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)),
|
||||
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, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump)),
|
||||
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, pump) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, ackPumpStep: pump));
|
||||
IWorkerRuntimeSession session = _runtimeSession;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using WNWRAPCONSUMERLib;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge path over AVEVA's modern managed alarm client,
|
||||
/// <c>aaAlarmManagedClient.AlarmClient</c> (the C++/CLI assembly the AVEVA
|
||||
/// Alarm Control itself uses: <c>AlarmClient → CwwAlarmConsumer →
|
||||
/// alarmlst.dll → alarmmgr.exe</c>). Preferred over a hand-rolled native
|
||||
/// WNAL consumer because <c>AlarmAckByGUID</c> 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 <see cref="WnalNativeAckConsumer"/> for why the native
|
||||
/// acquisition path is deliberately unfinished.
|
||||
///
|
||||
/// The <c>AlarmClient</c> members are <c>famorasm</c> (protected-internal)
|
||||
/// and mxaccessgw is not an <c>InternalsVisibleTo</c> 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 <c>StaRuntime.InvokeAsync</c>).
|
||||
///
|
||||
/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
|
||||
///
|
||||
/// OPEN (confirm at runtime, flagged inline): the trailing <c>bool</c> of
|
||||
/// <c>RegisterConsumer</c> (passed <c>false</c> here); whether
|
||||
/// <c>Subscribe</c> is actually required before an ack by GUID; and the
|
||||
/// native status returned on a real secured ack.
|
||||
/// </summary>
|
||||
public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
|
||||
{
|
||||
/// <summary>Default install location of the managed alarm client (eac.dll folder).</summary>
|
||||
public const string DefaultAssemblyPath =
|
||||
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
|
||||
|
||||
/// <summary>
|
||||
/// Env var that opts the worker into the managed ack path. Unset/anything
|
||||
/// other than "1" keeps the existing wnwrap ack behavior.
|
||||
/// </summary>
|
||||
public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
|
||||
|
||||
private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
|
||||
|
||||
/// <summary>
|
||||
/// Production factory for the <see cref="AlarmCommandHandler"/> seam:
|
||||
/// returns a managed ack client only when <see cref="EnableEnvVar"/> == "1",
|
||||
/// otherwise <see langword="null"/> (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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <param name="applicationName">Consumer application name (e.g. "MxGateway.ack").</param>
|
||||
/// <param name="assemblyPath">Override for <see cref="DefaultAssemblyPath"/>.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The consumer application name this instance registered under.</summary>
|
||||
public string ApplicationName { get; }
|
||||
|
||||
private static MethodInfo Resolve(Type type, string name) =>
|
||||
type.GetMethod(name, Inst)
|
||||
?? throw new MissingMethodException(TypeName, name);
|
||||
|
||||
/// <summary>
|
||||
/// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
|
||||
/// proven wnwrap argument values. <paramref name="subscription"/> is the
|
||||
/// alarm query root, e.g. <c>\\NODE\Galaxy!Area</c>. Throws if any native
|
||||
/// status is non-zero.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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,
|
||||
})!;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
|
||||
/// aaAlarmManagedClient's own enum parameter types (localQueryType /
|
||||
/// localSortFlags / localAlarmFilterState) via <see cref="Enum.ToObject(Type,object)"/>.
|
||||
/// </summary>
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
|
||||
try { uninitializeConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
|
||||
(client as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Operator identity an alarm acknowledgement authenticates as. The password
|
||||
/// is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
|
||||
/// and must never be logged or echoed.
|
||||
/// </summary>
|
||||
public sealed class AlarmAckOperator
|
||||
{
|
||||
/// <summary>Gets or sets the operator user name.</summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
|
||||
public string Domain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the operator password (credential-sensitive).</summary>
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
|
||||
public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
|
||||
public enum AlarmAckOutcome
|
||||
{
|
||||
/// <summary>The AckMsg write completed with an OK status.</summary>
|
||||
Applied,
|
||||
/// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
|
||||
Unconfirmed,
|
||||
/// <summary>The alarm is not in the consumer's current view.</summary>
|
||||
AlarmNotFound,
|
||||
/// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
|
||||
NotAckable,
|
||||
/// <summary>AuthenticateUser rejected the operator credentials.</summary>
|
||||
AuthenticationFailed,
|
||||
/// <summary>The AckMsg write completed with a non-OK status.</summary>
|
||||
WriteRejected,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>Result of an alarm acknowledgement.</summary>
|
||||
public sealed class AlarmAckResult
|
||||
{
|
||||
private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];
|
||||
|
||||
private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
|
||||
{
|
||||
Outcome = outcome;
|
||||
NativeStatus = nativeStatus;
|
||||
Diagnostic = diagnostic;
|
||||
Statuses = statuses ?? NoStatuses;
|
||||
}
|
||||
|
||||
/// <summary>Gets the outcome.</summary>
|
||||
public AlarmAckOutcome Outcome { get; }
|
||||
|
||||
/// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
|
||||
public int NativeStatus { get; }
|
||||
|
||||
/// <summary>Gets a diagnostic message. Never contains credentials.</summary>
|
||||
public string Diagnostic { get; }
|
||||
|
||||
/// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
|
||||
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
||||
|
||||
/// <summary>Creates a result.</summary>
|
||||
/// <param name="outcome">The outcome.</param>
|
||||
/// <param name="nativeStatus">The native status.</param>
|
||||
/// <param name="diagnostic">A credential-free diagnostic.</param>
|
||||
/// <param name="statuses">Observed write statuses.</param>
|
||||
/// <returns>The result.</returns>
|
||||
public static AlarmAckResult Create(
|
||||
AlarmAckOutcome outcome,
|
||||
int nativeStatus,
|
||||
string diagnostic,
|
||||
IReadOnlyList<MxStatusProxy>? statuses = null) =>
|
||||
new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);
|
||||
|
||||
/// <summary>Creates the result for an unresolved ack target.</summary>
|
||||
/// <param name="target">The unresolved target.</param>
|
||||
/// <returns>The result.</returns>
|
||||
public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
|
||||
new AlarmAckResult(target.Failure, -1, target.Reason, null);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
|
||||
public sealed class AlarmAckTarget
|
||||
{
|
||||
private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
|
||||
{
|
||||
ItemAddress = itemAddress;
|
||||
Failure = failure;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether an ack item was resolved.</summary>
|
||||
public bool Found => ItemAddress.Length > 0;
|
||||
|
||||
/// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
|
||||
public string ItemAddress { get; }
|
||||
|
||||
/// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
|
||||
public AlarmAckOutcome Failure { get; }
|
||||
|
||||
/// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
|
||||
public string Reason { get; }
|
||||
|
||||
/// <summary>Creates a resolved target.</summary>
|
||||
/// <param name="itemAddress">The ack item address.</param>
|
||||
/// <returns>The target.</returns>
|
||||
public static AlarmAckTarget For(string itemAddress) =>
|
||||
new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);
|
||||
|
||||
/// <summary>Creates a not-found result.</summary>
|
||||
/// <param name="reason">Why the alarm was not found.</param>
|
||||
/// <returns>The target.</returns>
|
||||
public static AlarmAckTarget NotFound(string reason) =>
|
||||
new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);
|
||||
|
||||
/// <summary>Creates a not-ackable result.</summary>
|
||||
/// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
|
||||
/// <returns>The target.</returns>
|
||||
public static AlarmAckTarget NotAckable(string reason) =>
|
||||
new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges alarms the way the AVEVA runtime records operator identity:
|
||||
/// AuthenticateUser as the operator, then write the comment to the alarm's
|
||||
/// <c>AckMsg</c> attribute with that user id. Owns its own
|
||||
/// <c>LMXProxyServerClass</c> 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.
|
||||
/// </summary>
|
||||
public sealed class AlarmAckWriter : IAlarmAckWriter
|
||||
{
|
||||
/// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
|
||||
public const string AckCommentAttribute = "AckMsg";
|
||||
|
||||
/// <summary>Default MXAccess client name when none is configured.</summary>
|
||||
public const string DefaultClientName = "MxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Default cap on cached ack item handles. Each cached item holds an
|
||||
/// advised MXAccess item for the worker's life, so the cache is bounded
|
||||
/// (least-recently-used eviction) rather than growing with every
|
||||
/// distinct alarm ever acknowledged.
|
||||
/// </summary>
|
||||
public const int DefaultMaxCachedItems = 256;
|
||||
|
||||
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 int maxCachedItems;
|
||||
private readonly Dictionary<string, LinkedListNode<AckItem>> itemHandles = new Dictionary<string, LinkedListNode<AckItem>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Most-recently-used first; the tail is the eviction candidate.
|
||||
private readonly LinkedList<AckItem> itemLru = new LinkedList<AckItem>();
|
||||
private object? comObject;
|
||||
private LMXProxyServerClass? comEventSource;
|
||||
private IMxAccessServer? server;
|
||||
private int serverHandle;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
|
||||
/// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
|
||||
/// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
|
||||
/// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
|
||||
/// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
|
||||
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!;
|
||||
maxCachedItems = DefaultMaxCachedItems;
|
||||
completionCache = new MxAccessWriteCompletionCache();
|
||||
}
|
||||
|
||||
/// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
|
||||
internal AlarmAckWriter(
|
||||
IMxAccessServer server,
|
||||
MxAccessWriteCompletionCache completionCache,
|
||||
Action pumpStep,
|
||||
TimeSpan completionTimeout,
|
||||
string clientName,
|
||||
int maxCachedItems = DefaultMaxCachedItems)
|
||||
{
|
||||
if (maxCachedItems < 1) throw new ArgumentOutOfRangeException(nameof(maxCachedItems));
|
||||
this.maxCachedItems = maxCachedItems;
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Resolves the configured client name from the launcher environment.</summary>
|
||||
/// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
|
||||
public static string ResolveClientName()
|
||||
{
|
||||
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
|
||||
return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 (Exception ex) when (IsInteropFailure(ex))
|
||||
{
|
||||
// Bad credentials surface as ArgumentException (E_INVALIDARG) from the
|
||||
// interop, not only COMException, so classify by the carried HRESULT.
|
||||
int authHresult = ex.HResult;
|
||||
if (IsServerGone(authHresult))
|
||||
{
|
||||
// The registration is dead, not the credentials: tear down so the
|
||||
// next ack re-registers, and report it as a write-path failure.
|
||||
TearDownServer();
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.WriteRejected,
|
||||
authHresult,
|
||||
$"AckMsg AuthenticateUser failed (HRESULT 0x{authHresult:X8}).");
|
||||
}
|
||||
|
||||
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).");
|
||||
}
|
||||
|
||||
ulong baselineVersion = 0;
|
||||
int itemHandle = 0;
|
||||
LinkedListNode<AckItem>? node;
|
||||
bool cached = itemHandles.TryGetValue(itemAddress, out node);
|
||||
string step = "AddItem";
|
||||
try
|
||||
{
|
||||
if (cached && node!.Value.Dirty)
|
||||
{
|
||||
// The previous ack to this item ended Unconfirmed, so its
|
||||
// OnWriteComplete may still arrive. Completions are keyed by
|
||||
// (server, item) handle with no per-write id, so a late one would
|
||||
// be credited to this write. Drop the item and re-add it so this
|
||||
// write waits on a fresh handle the stale completion cannot match.
|
||||
DropItem(mx, node);
|
||||
cached = false;
|
||||
}
|
||||
|
||||
if (cached)
|
||||
{
|
||||
itemHandle = node!.Value.Handle;
|
||||
itemLru.Remove(node);
|
||||
itemLru.AddFirst(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
itemHandle = mx.AddItem(serverHandle, itemAddress);
|
||||
node = CacheItem(mx, itemAddress, itemHandle);
|
||||
step = "Advise";
|
||||
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
|
||||
mx.Advise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
pumpStep();
|
||||
step = "Write";
|
||||
baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle);
|
||||
mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);
|
||||
}
|
||||
catch (Exception ex) when (IsInteropFailure(ex))
|
||||
{
|
||||
// The interop maps E_INVALIDARG to ArgumentException (and
|
||||
// E_ACCESSDENIED to UnauthorizedAccessException), so the HRESULT, not
|
||||
// the exception type, is what identifies the MXAccess failure.
|
||||
int hresult = ex.HResult;
|
||||
if (itemHandles.TryGetValue(itemAddress, out LinkedListNode<AckItem>? failed))
|
||||
{
|
||||
DropItem(mx, failed!);
|
||||
}
|
||||
|
||||
if (IsServerGone(hresult))
|
||||
{
|
||||
TearDownServer();
|
||||
}
|
||||
|
||||
// Diagnostic names the step and HRESULT only: exception text can echo the item or operator.
|
||||
return AlarmAckResult.Create(
|
||||
AlarmAckOutcome.WriteRejected,
|
||||
hresult,
|
||||
$"AckMsg {step} failed (HRESULT 0x{hresult:X8}).");
|
||||
}
|
||||
|
||||
ulong baseline = baselineVersion;
|
||||
|
||||
if (!completionCache.TryWaitForCompletion(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
baseline,
|
||||
DateTime.UtcNow + completionTimeout,
|
||||
pumpStep,
|
||||
out RepeatedField<MxStatusProxy> statuses))
|
||||
{
|
||||
node!.Value.Dirty = true;
|
||||
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 != Contracts.Proto.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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
TearDownServer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exceptions the MXAccess COM interop raises for a failed HRESULT: the
|
||||
/// runtime maps well-known HRESULTs to typed exceptions (E_INVALIDARG to
|
||||
/// ArgumentException, E_ACCESSDENIED to UnauthorizedAccessException) and
|
||||
/// the rest to COMException. Each carries the HRESULT in Exception.HResult.
|
||||
/// </summary>
|
||||
private static bool IsInteropFailure(Exception ex) =>
|
||||
ex is ExternalException
|
||||
|| ex is ArgumentException
|
||||
|| ex is UnauthorizedAccessException;
|
||||
|
||||
private static bool IsServerGone(int hresult) =>
|
||||
hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED
|
||||
|| hresult == unchecked((int)0x800706BA) // RPC_S_SERVER_UNAVAILABLE
|
||||
|| hresult == unchecked((int)0x80010105); // RPC_E_SERVERFAULT
|
||||
|
||||
/// <summary>Releases the MXAccess registration so the next call re-registers (also used by Dispose).</summary>
|
||||
private void TearDownServer()
|
||||
{
|
||||
if (comEventSource is not null)
|
||||
{
|
||||
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
IMxAccessServer? mx = server;
|
||||
if (mx is not null)
|
||||
{
|
||||
foreach (AckItem item in itemLru)
|
||||
{
|
||||
try { mx.UnAdvise(serverHandle, item.Handle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, item.Handle); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
try { mx.Unregister(serverHandle); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
itemHandles.Clear();
|
||||
itemLru.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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Caches a newly added item as most recently used, evicting the least recently used past the cap.</summary>
|
||||
private LinkedListNode<AckItem> CacheItem(IMxAccessServer mx, string itemAddress, int itemHandle)
|
||||
{
|
||||
while (itemLru.Count >= maxCachedItems && itemLru.Last is { } leastRecent)
|
||||
{
|
||||
DropItem(mx, leastRecent);
|
||||
}
|
||||
|
||||
LinkedListNode<AckItem> node = itemLru.AddFirst(new AckItem(itemAddress, itemHandle));
|
||||
itemHandles[itemAddress] = node;
|
||||
return node;
|
||||
}
|
||||
|
||||
/// <summary>Removes a cached item and best-effort releases its MXAccess item.</summary>
|
||||
private void DropItem(IMxAccessServer mx, LinkedListNode<AckItem> node)
|
||||
{
|
||||
itemHandles.Remove(node.Value.Address);
|
||||
itemLru.Remove(node);
|
||||
try { mx.UnAdvise(serverHandle, node.Value.Handle); } catch { /* best effort */ }
|
||||
try { mx.RemoveItem(serverHandle, node.Value.Handle); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private IMxAccessServer EnsureRegistered()
|
||||
{
|
||||
if (server is not null) return server;
|
||||
|
||||
if (factory is null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess server is gone and no COM factory is available to re-register.");
|
||||
}
|
||||
|
||||
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<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
|
||||
rows.AddRange(statusConverter.ConvertMany(pVars));
|
||||
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
|
||||
}
|
||||
|
||||
/// <summary>A cached ack item. Plain class (not a record) for the net48 worker target.</summary>
|
||||
private sealed class AckItem
|
||||
{
|
||||
public AckItem(string address, int handle)
|
||||
{
|
||||
Address = address;
|
||||
Handle = handle;
|
||||
}
|
||||
|
||||
public string Address { get; }
|
||||
|
||||
public int Handle { get; }
|
||||
|
||||
/// <summary>Set when the last write to this handle ended Unconfirmed; forces a fresh handle next time.</summary>
|
||||
public bool Dirty { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
private readonly Action? threadAffinityCheck;
|
||||
private readonly IMxAccessComObjectFactory? comFactory;
|
||||
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
|
||||
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
|
||||
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
|
||||
private readonly Action? ackPumpStep;
|
||||
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
|
||||
private readonly object syncRoot = new object();
|
||||
private readonly IAlarmAckWriter deferredAckWriter;
|
||||
private AlarmDispatcher? dispatcher;
|
||||
private IAlarmAckWriter? ackWriter;
|
||||
private FailoverAlarmConsumer? failoverConsumer;
|
||||
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
|
||||
private string subscribeSessionId = string.Empty;
|
||||
@@ -118,14 +122,22 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
|
||||
/// live COM factory.
|
||||
/// </param>
|
||||
/// <param name="managedAckFactory">
|
||||
/// Optional per-subscription factory for a managed ack client
|
||||
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
|
||||
/// client, GUID-keyed acknowledges route through AVEVA's modern
|
||||
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
|
||||
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
|
||||
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
|
||||
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
|
||||
/// <param name="ackWriterFactory">
|
||||
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
|
||||
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
|
||||
/// <paramref name="comFactory"/> that registers its own MXAccess server
|
||||
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
|
||||
/// The writer is created once per handler, on the first acknowledgement
|
||||
/// (so a subscription never fails because ack is unavailable), survives
|
||||
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
|
||||
/// inject a fake.
|
||||
/// </param>
|
||||
/// <param name="ackPumpStep">
|
||||
/// Pump step the default <see cref="AlarmAckWriter"/> drives while it waits
|
||||
/// for OnWriteComplete. Production passes the STA runtime's
|
||||
/// <c>StaRuntime.PumpPendingMessages</c>, which also refreshes the STA
|
||||
/// watchdog heartbeat; when <see langword="null"/> a bare
|
||||
/// <see cref="StaMessagePump"/> is used (no heartbeat refresh).
|
||||
/// </param>
|
||||
public AlarmCommandHandler(
|
||||
MxAccessEventQueue eventQueue,
|
||||
@@ -133,14 +145,17 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
Action? threadAffinityCheck,
|
||||
IMxAccessComObjectFactory? comFactory,
|
||||
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
|
||||
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
|
||||
Func<IAlarmAckWriter>? ackWriterFactory = null,
|
||||
Action? ackPumpStep = 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.ackPumpStep = ackPumpStep;
|
||||
this.deferredAckWriter = new DeferredAckWriter(this);
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
|
||||
@@ -181,14 +196,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 +332,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(
|
||||
alarmGuid,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
public AlarmAckResult AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||
return d.AcknowledgeByName(
|
||||
alarmName ?? string.Empty,
|
||||
providerName ?? string.Empty,
|
||||
groupName ?? string.Empty,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
ackOperator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -398,6 +391,62 @@ 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.");
|
||||
}
|
||||
|
||||
// The pump step is PeekMessage/DispatchMessage on the calling thread —
|
||||
// the STA, since acks run there — which is what delivers the writer's
|
||||
// OnWriteComplete callback. The injected StaRuntime pump also marks STA
|
||||
// activity so a long completion wait does not read as a hung STA.
|
||||
Action pump = ackPumpStep ?? (() => new StaMessagePump().PumpPendingMessages());
|
||||
ackWriter = new AlarmAckWriter(
|
||||
comFactory,
|
||||
pump,
|
||||
MxAccessStaSession.ResolveWriteCompletionTimeout(),
|
||||
AlarmAckWriter.ResolveClientName());
|
||||
return ackWriter;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
|
||||
/// handler's real writer on first acknowledgement so the writer (and its
|
||||
/// MXAccess server registration) is created only when an ack happens and
|
||||
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
|
||||
/// the real writer.
|
||||
/// </summary>
|
||||
private sealed class DeferredAckWriter : IAlarmAckWriter
|
||||
{
|
||||
private readonly AlarmCommandHandler owner;
|
||||
|
||||
public DeferredAckWriter(AlarmCommandHandler owner)
|
||||
{
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
|
||||
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private AlarmDispatcher GetDispatcherOrThrow()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
@@ -418,5 +467,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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
|
||||
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
|
||||
/// through <see cref="AlarmCommandHandler"/>, 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
|
||||
/// <see cref="IAlarmAckWriter"/>, which authenticates as the operator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
|
||||
@@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
private readonly MxAccessAlarmEventSink sink;
|
||||
private readonly string sessionId;
|
||||
private readonly EventHandler<MxAlarmTransitionEvent> handler;
|
||||
private IManagedAlarmAckClient? managedAck;
|
||||
private readonly IAlarmAckWriter ackWriter;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary>
|
||||
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer.</summary>
|
||||
/// <param name="consumer">The alarm consumer.</param>
|
||||
/// <param name="sink">The alarm event sink.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="managedAck">
|
||||
/// Optional managed ack client. When non-null, GUID-keyed acknowledges
|
||||
/// route to it (AVEVA's modern <c>AlarmClient.AlarmAckByGUID</c>) 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.
|
||||
/// <param name="ackWriter">
|
||||
/// Writes the resolved ack item as the authenticated operator. Owned by
|
||||
/// the caller (<see cref="AlarmCommandHandler"/>), which disposes it; the
|
||||
/// dispatcher never does, so the writer's registered MXAccess server
|
||||
/// outlives an unsubscribe/resubscribe cycle.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the consumer is currently serving alarms from the subtag
|
||||
/// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr.
|
||||
/// </summary>
|
||||
private bool IsSubtagActive() =>
|
||||
consumer is SubtagAlarmConsumer ||
|
||||
(consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag);
|
||||
|
||||
/// <summary>
|
||||
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
|
||||
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
|
||||
/// status code (0 = success).
|
||||
/// </summary>
|
||||
/// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
||||
/// <param name="ackOperatorName">The operator name.</param>
|
||||
/// <param name="ackOperatorNode">The operator node.</param>
|
||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
||||
/// <param name="ackOperatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
/// <param name="ackComment">The acknowledgement comment.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge an alarm by its (name, provider, group) tuple.
|
||||
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
|
||||
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
|
||||
/// </summary>
|
||||
/// <param name="alarmName">The alarm name.</param>
|
||||
/// <param name="providerName">The provider name.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
||||
/// <param name="ackOperatorName">The operator name.</param>
|
||||
/// <param name="ackOperatorNode">The operator node.</param>
|
||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
||||
/// <param name="ackOperatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
public int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
/// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
|
||||
/// <param name="alarmName">The tag/alarm name.</param>
|
||||
/// <param name="providerName">The provider.</param>
|
||||
/// <param name="groupName">The group/area.</param>
|
||||
/// <param name="ackComment">The acknowledgement comment.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -244,6 +169,7 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
|
||||
string fullReference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
record.ProviderName, record.Group, record.TagName);
|
||||
bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record);
|
||||
|
||||
sink.EnqueueTransition(
|
||||
alarmFullReference: fullReference,
|
||||
@@ -253,15 +179,22 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
severity: record.Priority,
|
||||
originalRaiseTimestampUtc: null,
|
||||
transitionTimestampUtc: record.TransitionTimestampUtc,
|
||||
operatorUser: record.OperatorName,
|
||||
operatorComment: record.AlarmComment,
|
||||
operatorUser: useAck ? record.AckOperatorName : record.OperatorName,
|
||||
operatorComment: useAck ? record.AckComment : record.AlarmComment,
|
||||
category: record.Group,
|
||||
description: string.Empty,
|
||||
degraded: record.Degraded);
|
||||
}
|
||||
|
||||
private static bool HasAckAttribution(MxAlarmSnapshotRecord record) =>
|
||||
!string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment);
|
||||
|
||||
private static bool IsAcked(MxAlarmStateKind state) =>
|
||||
state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn;
|
||||
|
||||
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated)
|
||||
{
|
||||
bool useAck = IsAcked(record.State) && HasAckAttribution(record);
|
||||
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
@@ -270,8 +203,8 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
AlarmTypeName = record.Type,
|
||||
CurrentState = MapConditionState(record.State),
|
||||
Severity = record.Priority,
|
||||
OperatorUser = record.OperatorName,
|
||||
OperatorComment = record.AlarmComment,
|
||||
OperatorUser = useAck ? record.AckOperatorName : record.OperatorName,
|
||||
OperatorComment = useAck ? record.AckComment : record.AlarmComment,
|
||||
Category = record.Group,
|
||||
Description = string.Empty,
|
||||
Degraded = record.Degraded,
|
||||
@@ -319,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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// <strong>Threading.</strong> Like its children, this type is driven
|
||||
/// entirely on the worker's STA: <see cref="Subscribe"/>,
|
||||
/// <see cref="PollOnce"/>, <see cref="ProbeOnce"/>, and the
|
||||
/// <c>AcknowledgeBy*</c> calls are all invoked from the apartment that
|
||||
/// <c>ResolveAckTarget*</c> calls are all invoked from the apartment that
|
||||
/// owns the underlying COM objects. It owns no locks of its own and no
|
||||
/// internal timer; the worker drives <see cref="PollOnce"/> on a timer.
|
||||
/// </para>
|
||||
@@ -259,34 +259,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
|
||||
return ActiveChild.AcknowledgeByGuid(
|
||||
alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
|
||||
return ActiveChild.ResolveAckTarget(alarmGuid);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
|
||||
return ActiveChild.AcknowledgeByName(
|
||||
alarmName, providerName, groupName, ackComment,
|
||||
ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
|
||||
return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
|
||||
/// after authenticating as the operator. Must be called on the worker STA.
|
||||
/// </summary>
|
||||
public interface IAlarmAckWriter : IDisposable
|
||||
{
|
||||
/// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
|
||||
/// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
|
||||
/// <param name="comment">The acknowledgement comment.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
|
||||
}
|
||||
@@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable
|
||||
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
|
||||
void Unsubscribe();
|
||||
|
||||
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
|
||||
/// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="comment">The acknowledgment comment.</param>
|
||||
/// <param name="operatorUser">The operator user name.</param>
|
||||
/// <param name="operatorNode">The operator node name.</param>
|
||||
/// <param name="operatorDomain">The operator domain name.</param>
|
||||
/// <param name="operatorFullName">The operator full name.</param>
|
||||
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
|
||||
int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
|
||||
/// <returns>The acknowledgement outcome.</returns>
|
||||
AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a single alarm by (name, provider, group) — used when
|
||||
/// the caller has the human-readable reference but not the GUID.
|
||||
/// </summary>
|
||||
/// <param name="alarmName">The alarm name.</param>
|
||||
/// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
|
||||
/// <param name="alarmName">The alarm (tag) name.</param>
|
||||
/// <param name="providerName">The provider name.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="comment">The acknowledgment comment.</param>
|
||||
/// <param name="operatorUser">The operator user name.</param>
|
||||
/// <param name="operatorNode">The operator node name.</param>
|
||||
/// <param name="operatorDomain">The operator domain name.</param>
|
||||
/// <param name="operatorFullName">The operator full name.</param>
|
||||
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
|
||||
int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
|
||||
/// <returns>The acknowledgement outcome.</returns>
|
||||
AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the currently-active alarm set, optionally scoped to a
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Optional ack-only seam over AVEVA's modern managed alarm client
|
||||
/// (<c>aaAlarmManagedClient.AlarmClient</c>). When supplied to an
|
||||
/// <see cref="AlarmDispatcher"/>, GUID-keyed acknowledges route here
|
||||
/// instead of the wnwrap COM consumer's <c>AlarmAckByGUID</c> (which is
|
||||
/// <c>E_NOTIMPL</c> on current builds) — carrying full operator identity
|
||||
/// (domain + full name) that the wnwrap 6-arg <c>AlarmAckByName</c> drops.
|
||||
/// The production implementation is <see cref="AaManagedAlarmAckConsumer"/>.
|
||||
/// </summary>
|
||||
public interface IManagedAlarmAckClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the client's Initialize → Register → Subscribe lifecycle against
|
||||
/// the same subscription expression the read consumer uses
|
||||
/// (<c>\\<node>\Galaxy!<area></c>). Must be called once,
|
||||
/// on the worker STA, before <see cref="AcknowledgeByGuid"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The subscription expression.</param>
|
||||
void Subscribe(string subscription);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="comment">The acknowledgment comment.</param>
|
||||
/// <param name="operatorName">The operator name.</param>
|
||||
/// <param name="operatorNode">The operator node.</param>
|
||||
/// <param name="operatorDomain">The operator domain.</param>
|
||||
/// <param name="operatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorName,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
}
|
||||
@@ -49,51 +49,19 @@ public interface IMxAccessAlarmConsumer : IDisposable
|
||||
void Subscribe(string subscription);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges a single alarm with full operator-identity fidelity.
|
||||
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
|
||||
/// user / node / domain / full-name and the comment land atomically
|
||||
/// with the ack transition in the alarm-history log.
|
||||
/// Resolves the MXAccess item whose write acknowledges the alarm with the
|
||||
/// given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
|
||||
/// </summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
||||
/// <param name="ackOperatorName">The operator name.</param>
|
||||
/// <param name="ackOperatorNode">The operator node.</param>
|
||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
||||
/// <param name="ackOperatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName);
|
||||
/// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
|
||||
/// <returns>The resolved target or the reason none exists.</returns>
|
||||
AlarmAckTarget ResolveAckTarget(Guid alarmGuid);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a single alarm by its (name, provider, group) tuple.
|
||||
/// Reaches AVEVA's <c>AlarmAckByName</c> on
|
||||
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as
|
||||
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the
|
||||
/// human-readable reference but not the canonical GUID.
|
||||
/// </summary>
|
||||
/// <param name="alarmName">The alarm name.</param>
|
||||
/// <param name="providerName">The provider name.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
||||
/// <param name="ackOperatorName">The operator name.</param>
|
||||
/// <param name="ackOperatorNode">The operator node.</param>
|
||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
||||
/// <param name="ackOperatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName);
|
||||
/// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
|
||||
/// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
|
||||
/// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
|
||||
/// <param name="groupName">The area/group.</param>
|
||||
/// <returns>The resolved target or the reason none exists.</returns>
|
||||
AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the consumer's most recently parsed snapshot of currently
|
||||
|
||||
@@ -16,9 +16,4 @@ public interface ISubtagAlarmSource : IDisposable
|
||||
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
|
||||
/// <param name="itemAddresses">The subtag references to advise.</param>
|
||||
void Advise(IReadOnlyCollection<string> itemAddresses);
|
||||
|
||||
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
|
||||
/// <param name="itemAddress">The subtag reference to write.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
void Write(string itemAddress, object? value);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
|
||||
/// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
|
||||
/// item addresses, normalizes each <c>OnDataChange</c> COM callback into a
|
||||
/// <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an
|
||||
/// ack-comment) to a subtag.
|
||||
/// <see cref="SubtagValueChange"/>. It is observe-only: alarm
|
||||
/// acknowledgement goes through <see cref="AlarmAckWriter"/>, which
|
||||
/// authenticates the operator on its own server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -25,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
|
||||
/// apartment-threaded and delivers <c>OnDataChange</c> via the STA
|
||||
/// message pump. Every method on this type — the lazy <see cref="Advise"/>
|
||||
/// creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be
|
||||
/// creation and <see cref="Dispose"/> — must be
|
||||
/// invoked on the worker's STA that created the source, exactly as
|
||||
/// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
|
||||
/// require. The <c>OnDataChange</c> COM event also arrives on that STA.
|
||||
@@ -47,9 +48,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
|
||||
new Dictionary<int, string>();
|
||||
|
||||
// Handles that were actually Advise()d, tracked separately from the added
|
||||
// set so Dispose only UnAdvises advised items. Write() can AddItem a
|
||||
// write-only subtag (e.g. an ack-comment that was never advised); calling
|
||||
// UnAdvise on such a handle would be an unbalanced teardown.
|
||||
// set so Dispose only UnAdvises items this source advised.
|
||||
private readonly HashSet<int> advisedItemHandles = new HashSet<int>();
|
||||
|
||||
private object? mxAccessComObject;
|
||||
@@ -85,7 +84,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
|
||||
/// <see cref="IMxAccessServer"/> with a pre-registered server handle,
|
||||
/// bypassing the live COM factory. Exposed via
|
||||
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
|
||||
/// exercise <see cref="Advise"/>/<see cref="Write"/> and
|
||||
/// exercise <see cref="Advise"/> and
|
||||
/// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
|
||||
/// No COM event source is wired in this mode — tests drive
|
||||
/// <see cref="HandleDataChange"/> directly to simulate an
|
||||
@@ -140,29 +139,6 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(string itemAddress, object? value)
|
||||
{
|
||||
// Writes with MXAccess user id 0 — always an unsecured Write, never WriteSecured semantics.
|
||||
if (itemAddress is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(itemAddress));
|
||||
}
|
||||
|
||||
ThrowIfDisposed();
|
||||
EnsureRegistered();
|
||||
|
||||
IMxAccessServer mxServer = server!;
|
||||
if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
|
||||
{
|
||||
itemHandle = mxServer.AddItem(serverHandle, itemAddress);
|
||||
itemHandlesByAddress[itemAddress] = itemHandle;
|
||||
addressesByItemHandle[itemHandle] = itemAddress;
|
||||
}
|
||||
|
||||
mxServer.Write(serverHandle, itemHandle, value, userId: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes an MXAccess <c>OnDataChange</c> callback into a
|
||||
/// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
|
||||
@@ -223,8 +199,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
|
||||
{
|
||||
foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
|
||||
{
|
||||
// Only UnAdvise handles that were actually advised; a write-only
|
||||
// item (added by Write but never Advise'd) was never advised.
|
||||
// Only UnAdvise handles that were actually advised.
|
||||
if (advisedItemHandles.Contains(entry.Key))
|
||||
{
|
||||
try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an exception escaping an acknowledgement (e.g. the ack writer's
|
||||
/// MXAccess Register failing, or no active subscription) to an
|
||||
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> 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.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -48,7 +48,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
private readonly IMxAccessEventSink eventSink;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly StaRuntime staRuntime;
|
||||
private readonly Func<MxAccessEventQueue, Action, IMxAccessComObjectFactory, IAlarmCommandHandler>? alarmCommandHandlerFactory;
|
||||
// Pump-aware form: the fourth argument is the STA runtime's pump step, so
|
||||
// handler work that pumps while it waits (the alarm ack writer's
|
||||
// OnWriteComplete wait) also refreshes the STA watchdog heartbeat.
|
||||
private readonly Func<MxAccessEventQueue, Action, IMxAccessComObjectFactory, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory;
|
||||
private StaCommandDispatcher? commandDispatcher;
|
||||
private MxAccessSession? session;
|
||||
private IAlarmCommandHandler? alarmCommandHandler;
|
||||
@@ -96,6 +99,23 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default dependencies and a
|
||||
/// pump-aware alarm-command handler factory. Its fourth argument is a pump step bound to this
|
||||
/// session's <see cref="StaRuntime"/> (<see cref="StaRuntime.PumpPendingMessages"/>), which
|
||||
/// refreshes the STA watchdog heartbeat on every pump.
|
||||
/// </summary>
|
||||
/// <param name="pumpAwareAlarmCommandHandlerFactory">Factory that constructs the alarm-command handler.</param>
|
||||
internal MxAccessStaSession(Func<MxAccessEventQueue, Action, IMxAccessComObjectFactory, Action, IAlarmCommandHandler> pumpAwareAlarmCommandHandlerFactory)
|
||||
{
|
||||
staRuntime = new StaRuntime();
|
||||
factory = new MxAccessComObjectFactory();
|
||||
eventQueue = new MxAccessEventQueue(MxAccessEventQueue.ResolveCapacity());
|
||||
eventSink = new MxAccessBaseEventSink(eventQueue);
|
||||
alarmCommandHandlerFactory = pumpAwareAlarmCommandHandlerFactory
|
||||
?? throw new ArgumentNullException(nameof(pumpAwareAlarmCommandHandlerFactory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory.
|
||||
/// </summary>
|
||||
@@ -183,7 +203,9 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
|
||||
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory is null
|
||||
? null
|
||||
: (eq, affinity, comFactory, _) => alarmCommandHandlerFactory(eq, affinity, comFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -288,7 +310,8 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
alarmCommandHandler = alarmCommandHandlerFactory(
|
||||
eventQueue,
|
||||
EnsureOnAlarmConsumerThread,
|
||||
factory);
|
||||
factory,
|
||||
() => staRuntime.PumpPendingMessages());
|
||||
}
|
||||
commandDispatcher = new StaCommandDispatcher(
|
||||
staRuntime,
|
||||
|
||||
@@ -39,6 +39,18 @@ public sealed class MxAlarmSnapshotRecord
|
||||
public string OperatorName { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the alarm comment.</summary>
|
||||
public string AlarmComment { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the acknowledgement comment (wnwrap <c>ACK_COMMENT</c>).</summary>
|
||||
public string AckComment { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the acknowledging operator's name (wnwrap <c>ACK_OPERATOR_NAME</c>).</summary>
|
||||
public string AckOperatorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the acknowledging operator's domain (wnwrap <c>ACK_DOMAIN_NAME</c>).</summary>
|
||||
public string AckOperatorDomain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap <c>ACK_OPERATOR_NODE</c>).</summary>
|
||||
public string AckOperatorNode { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this record was synthesized
|
||||
/// by the subtag-provider fallback rather than emitted by the native
|
||||
|
||||
@@ -27,9 +27,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// <para>
|
||||
/// The subtag path is event-driven, so <see cref="PollOnce"/> is a
|
||||
/// no-op — value changes arrive through the source's advise stream
|
||||
/// rather than an explicit poll. Acknowledgment in subtag mode writes
|
||||
/// the comment to the target's writable ack-comment subtag rather than
|
||||
/// calling a native <c>AlarmAckByGUID</c> / <c>AlarmAckByName</c>.
|
||||
/// rather than an explicit poll. Acknowledgment in subtag mode resolves
|
||||
/// to the target's writable ack-comment subtag
|
||||
/// (<see cref="ResolveAckTarget"/>); <see cref="AlarmAckWriter"/>
|
||||
/// performs the operator-authenticated write on its own MXAccess
|
||||
/// server, so this consumer neither advises nor writes that subtag.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
@@ -49,7 +51,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
/// Initializes the consumer over a subtag source and a watch list of
|
||||
/// alarm targets.
|
||||
/// </summary>
|
||||
/// <param name="source">The subtag value source to advise and write through.</param>
|
||||
/// <param name="source">The subtag value source to advise.</param>
|
||||
/// <param name="watchList">The alarm subtag targets to observe.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="source"/> or <paramref name="watchList"/>
|
||||
@@ -91,7 +93,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
AddIfNotEmpty(addresses, target.ActiveSubtag);
|
||||
AddIfNotEmpty(addresses, target.AckedSubtag);
|
||||
AddIfNotEmpty(addresses, target.PrioritySubtag);
|
||||
AddIfNotEmpty(addresses, target.AckCommentSubtag);
|
||||
}
|
||||
|
||||
source.Advise(addresses);
|
||||
@@ -104,13 +105,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
@@ -120,22 +115,14 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
|
||||
!targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
|
||||
{
|
||||
return -1;
|
||||
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list.");
|
||||
}
|
||||
|
||||
return WriteAckComment(target, ackComment);
|
||||
return ToAckTarget(target);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
@@ -143,12 +130,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
}
|
||||
|
||||
AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
|
||||
if (target is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return WriteAckComment(target, ackComment);
|
||||
return target is null
|
||||
? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.")
|
||||
: ToAckTarget(target);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -260,16 +244,12 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
return target;
|
||||
}
|
||||
|
||||
private int WriteAckComment(AlarmSubtagTarget target, string ackComment)
|
||||
private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target)
|
||||
{
|
||||
string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(ackCommentSubtag))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
source.Write(ackCommentSubtag, ackComment);
|
||||
return 0;
|
||||
string ackItem = target.AckCommentSubtag ?? string.Empty;
|
||||
return ackItem.Length == 0
|
||||
? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.")
|
||||
: AlarmAckTarget.For(ackItem);
|
||||
}
|
||||
|
||||
private static void AddIfNotEmpty(List<string> addresses, string? address)
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// and operated from an STA thread; the worker's
|
||||
/// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
|
||||
/// The consumer owns <em>no</em> internal timer: every COM call
|
||||
/// (<c>Subscribe</c>, <c>PollOnce</c>, <c>AcknowledgeBy*</c>) must
|
||||
/// (<c>Subscribe</c>, <c>PollOnce</c>) must
|
||||
/// be invoked on the STA that created the consumer. Polling cadence
|
||||
/// is driven externally by the worker's STA via
|
||||
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c>, which
|
||||
@@ -43,6 +43,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// off the owning STA and can deadlock on cross-apartment marshaling
|
||||
/// when the STA is not pumping messages, so no such timer exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Acknowledgement.</strong> This consumer only receives
|
||||
/// alarms. The wnwrap <c>AlarmAckBy*</c> calls were accepted and then
|
||||
/// inert on the dev rig (see <c>docs/AlarmProbeFindings.md</c>), so
|
||||
/// acknowledgement no longer goes through wnwrap: the consumer resolves
|
||||
/// a Galaxy alarm to its <c><tag>.AckMsg</c> item
|
||||
/// (<see cref="ResolveAckTarget"/>) and <see cref="AlarmAckWriter"/>
|
||||
/// writes that item over MXAccess as the authenticated operator.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
{
|
||||
@@ -50,6 +59,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
|
||||
private const string DefaultVersion = "1.0";
|
||||
private const int DefaultMaxAlarmsPerFetch = 1024;
|
||||
private const string GalaxyProviderName = "Galaxy";
|
||||
|
||||
/// <summary>
|
||||
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side
|
||||
@@ -93,7 +103,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
private long truncatedFetchCount;
|
||||
private bool lastSnapshotTruncated;
|
||||
private wwAlarmConsumerClass? client;
|
||||
private wwAlarmConsumerClass? ackClient;
|
||||
private bool subscribed;
|
||||
private bool disposed;
|
||||
|
||||
@@ -193,14 +202,14 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
|
||||
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
|
||||
// lifecycle. Empirically (live dev-rig 2026-05-01, reconfirmed
|
||||
// 2026-08-18) this is the only path that lets AlarmAckByName
|
||||
// return rc=0 afterwards. The v2 Initialize/Register/Subscribe
|
||||
// methods on the class succeed (return 0) but acks against that
|
||||
// 2026-08-18) the v1 path is the one that leaves the consumer in
|
||||
// a usable state: the v2 Initialize/Register/Subscribe methods on
|
||||
// the class succeed (return 0) but later calls against that
|
||||
// consumer state return -55. The v1 prefix path is what
|
||||
// WIN-911-style code uses against the same wnwrap library.
|
||||
// Note rc=0 means the call was
|
||||
// accepted, not that an acknowledgement was applied — see
|
||||
// AcknowledgeByName below and docs/AlarmProbeFindings.md.
|
||||
// WIN-911-style code uses against the same wnwrap library. This
|
||||
// consumer only reads the alarm snapshot; acknowledgements go
|
||||
// through AlarmAckWriter, not through wnwrap (see
|
||||
// docs/AlarmProbeFindings.md).
|
||||
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
|
||||
if (init != 0)
|
||||
{
|
||||
@@ -241,10 +250,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
|
||||
// calling it is necessary for subsequent GetXmlCurrentAlarms2
|
||||
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
|
||||
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
|
||||
// also breaks AlarmAckByName on the same consumer (rejects with
|
||||
// -55), so a separate ack-only consumer is provisioned below
|
||||
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
|
||||
// E_FAIL (HRESULT 0x80004005) on the first poll.
|
||||
//
|
||||
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
|
||||
// — there is no integer return code to gate on like the other v1
|
||||
@@ -268,116 +274,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
ex);
|
||||
}
|
||||
|
||||
// Provision a parallel COM consumer for ack calls. It runs the
|
||||
// v1 lifecycle (Initialize/Register/Subscribe) only; without
|
||||
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
|
||||
// — we never poll this consumer.
|
||||
ackClient = new wwAlarmConsumerClass();
|
||||
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
|
||||
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: DefaultProductName,
|
||||
szApplicationName: DefaultApplicationName + ".ack",
|
||||
szVersion: DefaultVersion);
|
||||
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
|
||||
szSubscription: subscription,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Ack consumer setup returned non-zero status: " +
|
||||
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
|
||||
}
|
||||
|
||||
subscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
MxAlarmSnapshotRecord? record;
|
||||
lock (syncRoot)
|
||||
{
|
||||
latestSnapshot.TryGetValue(alarmGuid, out record);
|
||||
}
|
||||
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
if (record is null)
|
||||
{
|
||||
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
|
||||
}
|
||||
|
||||
// VBGUID is wnwrap's GUID interop struct (same memory layout as
|
||||
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
|
||||
// unmanaged-blittable round-trip.
|
||||
VBGUID vb = ToVbGuid(alarmGuid);
|
||||
return com.AlarmAckByGUID(
|
||||
AlmGUID: vb,
|
||||
szComment: ackComment ?? string.Empty,
|
||||
szOprName: ackOperatorName ?? string.Empty,
|
||||
szNode: ackOperatorNode ?? string.Empty,
|
||||
szDomainName: ackOperatorDomain ?? string.Empty,
|
||||
szOprFullName: ackOperatorFullName ?? string.Empty);
|
||||
return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
return ResolveGalaxyAckTarget(providerName, alarmName);
|
||||
}
|
||||
|
||||
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
|
||||
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
|
||||
// empirical justification.
|
||||
wwAlarmConsumerClass com = ackClient
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
|
||||
private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
|
||||
{
|
||||
if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AlarmAckTarget.NotAckable(
|
||||
$"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
|
||||
}
|
||||
|
||||
// The original observation (live dev-rig 2026-05-01) was narrower than
|
||||
// it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55
|
||||
// "and looked like a stub" while the legacy 6-arg
|
||||
// IwwAlarmConsumer.AlarmAckByName returned 0, which is how the 6-arg
|
||||
// overload came to be the one called here. The 2026-08-18 probe
|
||||
// corrected both halves. The -55 tracks the *consumer*, not the
|
||||
// overload: both overloads return -55 against a
|
||||
// SetXmlAlarmQuery-applied consumer and 0 against the ack-only one
|
||||
// used above. And a zero return is NOT evidence the acknowledgement
|
||||
// was applied — the probe acked a real, freshly raised alarm six ways
|
||||
// and watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty,
|
||||
// and the alarm extension's own .Acked attribute stay False for 16 s
|
||||
// after each call. On that rig the ack is accepted and then inert, so
|
||||
// whether any wnwrap ack reaches the alarm-history path is
|
||||
// unverified; see docs/AlarmProbeFindings.md and the 2026-08-18
|
||||
// correction in docs/AlarmClientDiscovery.md. The 6-arg call site
|
||||
// below therefore stays as-is for MXAccess parity — the choice is no
|
||||
// longer justified by the rc, and rc semantics are per the probe.
|
||||
//
|
||||
// Operator-domain and operator-full-name fields are accepted by the
|
||||
// proto contract for forward-compat but are not propagated to AVEVA
|
||||
// today — wrapped in the 6-arg call so domain/full-name go to the
|
||||
// alarm-history operator-name field via the szOprName parameter.
|
||||
// Suppress unused-warning explicitly:
|
||||
_ = ackOperatorDomain;
|
||||
_ = ackOperatorFullName;
|
||||
return com.AlarmAckByName(
|
||||
szAlarmName: alarmName ?? string.Empty,
|
||||
szProviderName: providerName ?? string.Empty,
|
||||
szGroupName: groupName ?? string.Empty,
|
||||
szComment: ackComment ?? string.Empty,
|
||||
szOprName: ackOperatorName ?? string.Empty,
|
||||
szNode: ackOperatorNode ?? string.Empty);
|
||||
if (string.IsNullOrWhiteSpace(tagName))
|
||||
{
|
||||
return AlarmAckTarget.NotFound("Alarm record has no tag name.");
|
||||
}
|
||||
|
||||
return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -676,10 +615,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
/// place under its original GUID and the re-raise replaced it with a
|
||||
/// freshly minted one. That is the intended reading — the old
|
||||
/// instance ended and a new one began — so the diff emits a Clear
|
||||
/// plus a Raise, which is what happened. The acknowledge leg remains
|
||||
/// assumed rather than observed: on the dev rig every ack surface is
|
||||
/// inert (<c>AlarmAckByName</c> returns 0 and nothing moves), so no
|
||||
/// record can be driven into an acknowledged state — see
|
||||
/// plus a Raise, which is what happened. Every alarm-client ack surface
|
||||
/// is inert on the dev rig (it returns 0 and nothing moves), so
|
||||
/// acknowledgements go through the AckMsg writer; an AckMsg write
|
||||
/// moves the record to <c>ACK_ALM</c> (observed 2026-10-19) — see
|
||||
/// <c>docs/AlarmProbeFindings.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
@@ -792,6 +731,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
string? operatorNode = null;
|
||||
string? operatorName = null;
|
||||
string? alarmComment = null;
|
||||
string? ackComment = null;
|
||||
string? ackOperatorName = null;
|
||||
string? ackOperatorDomain = null;
|
||||
string? ackOperatorNode = null;
|
||||
|
||||
foreach (XmlNode child in alarmNode.ChildNodes)
|
||||
{
|
||||
@@ -816,6 +759,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
|
||||
case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
|
||||
case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
|
||||
case "ACK_COMMENT": ackComment ??= child.InnerText; break;
|
||||
case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break;
|
||||
case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break;
|
||||
case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -843,6 +790,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
OperatorNode = operatorNode ?? string.Empty,
|
||||
OperatorName = operatorName ?? string.Empty,
|
||||
AlarmComment = alarmComment ?? string.Empty,
|
||||
AckComment = ackComment ?? string.Empty,
|
||||
AckOperatorName = ackOperatorName ?? string.Empty,
|
||||
AckOperatorDomain = ackOperatorDomain ?? string.Empty,
|
||||
AckOperatorNode = ackOperatorNode ?? string.Empty,
|
||||
};
|
||||
}
|
||||
return records;
|
||||
@@ -932,37 +883,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static VBGUID ToVbGuid(Guid g)
|
||||
{
|
||||
byte[] bytes = g.ToByteArray();
|
||||
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
|
||||
VBGUID vb = new VBGUID
|
||||
{
|
||||
Data1 = BitConverter.ToInt32(bytes, 0),
|
||||
Data2 = BitConverter.ToInt16(bytes, 4),
|
||||
Data3 = BitConverter.ToInt16(bytes, 6),
|
||||
Data4 = new byte[8],
|
||||
};
|
||||
Array.Copy(bytes, 8, vb.Data4, 0, 8);
|
||||
return vb;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
wwAlarmConsumerClass? clientToDispose;
|
||||
wwAlarmConsumerClass? ackClientToDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clientToDispose = client;
|
||||
client = null;
|
||||
ackClientToDispose = ackClient;
|
||||
ackClient = null;
|
||||
}
|
||||
ReleaseConsumerCom(clientToDispose);
|
||||
ReleaseConsumerCom(ackClientToDispose);
|
||||
}
|
||||
|
||||
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Native alarm-acknowledge path over <c>alarmlst.dll</c>'s public <c>WNAL*</c>
|
||||
/// C exports (the same stack the AVEVA Alarm Control uses:
|
||||
/// <c>aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe</c>).
|
||||
///
|
||||
/// Why this exists alongside <see cref="WnWrapAlarmConsumer"/>: the legacy
|
||||
/// <c>WNWRAPCONSUMERLib</c> COM surface cannot carry a full/secured ack —
|
||||
/// <c>AlarmAckByGUID</c> is <c>E_NOTIMPL</c>, the 8-arg <c>AlarmAckByName</c>
|
||||
/// is a vendor stub (-55), and neither exposes the signed-ack password. The
|
||||
/// native request record built by <see cref="WnalReqSetText"/> has six text
|
||||
/// fields — node, operator, comment, <b>password</b>, 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 <c>StaRuntime.InvokeAsync</c>).
|
||||
///
|
||||
/// x86 ONLY: <c>alarmlst.dll</c> is 32-bit; this compiles into the x86 worker.
|
||||
///
|
||||
/// STATUS: the ack-SUBMIT contract (<see cref="WnalReqSetText"/>,
|
||||
/// <see cref="WnalUserRequest"/>, <see cref="WnalUserRequestEx"/>) 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 <c>hAlarmList</c> + 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.
|
||||
/// </summary>
|
||||
public sealed class WnalNativeAckConsumer
|
||||
{
|
||||
private const string AlarmLst = "alarmlst.dll";
|
||||
|
||||
/// <summary>Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else.</summary>
|
||||
private const ushort AlmAckVersion = 0x104;
|
||||
|
||||
/// <summary>Byte offset where WNALalarmReqSetText begins the string-offset table + heap.</summary>
|
||||
private const int StringHeapBase = 0x42;
|
||||
|
||||
/// <summary>Sentinel the exports reject as an invalid list handle.</summary>
|
||||
public const int InvalidAlarmList = -1;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Pack operator identity + comment into an existing request record.
|
||||
/// Arg order is confirmed from the debug format string. Per-field rule:
|
||||
/// pass <c>null</c> 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 <c>recSize</c>; 0 = <paramref name="lpAlReq"/> was null.
|
||||
/// </summary>
|
||||
[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);
|
||||
|
||||
/// <summary>
|
||||
/// Submit a user request (an ack) over the alarms SELECTED in
|
||||
/// <paramref name="hAlarmList"/>. 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.
|
||||
/// </summary>
|
||||
[DllImport(AlarmLst, EntryPoint = "WNALUserRequest",
|
||||
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
|
||||
private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck);
|
||||
|
||||
/// <summary>
|
||||
/// Submit a user request (an ack) against an EXPLICIT array of
|
||||
/// <paramref name="count"/> 4-byte alarm handles — no selection state
|
||||
/// required. <paramref name="a4"/> mirrors the scope/flag scalar.
|
||||
/// Returns 0 on success.
|
||||
/// </summary>
|
||||
[DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx",
|
||||
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
|
||||
private static extern int WnalUserRequestEx(
|
||||
int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck);
|
||||
|
||||
/// <summary>Acknowledge-scope values for the a4 argument.</summary>
|
||||
private enum AckScope
|
||||
{
|
||||
All = 0,
|
||||
Selected = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a request record and submit an ack for the given alarm
|
||||
/// handles via <see cref="WnalUserRequestEx"/>. Caller supplies a live
|
||||
/// <paramref name="hAlarmList"/> and the handle(s) obtained from the
|
||||
/// list's records (acquisition is not implemented — see the class note).
|
||||
/// Returns the native status (0 == success).
|
||||
/// </summary>
|
||||
/// <param name="hAlarmList">A registered, subscribed alarm-list handle.</param>
|
||||
/// <param name="alarmHandles">The 4-byte alarm handles to acknowledge.</param>
|
||||
/// <param name="comment">Ack comment.</param>
|
||||
/// <param name="operatorName">Operator / user name.</param>
|
||||
/// <param name="operatorNode">Operator node name.</param>
|
||||
/// <param name="operatorDomain">Operator domain (carried natively, unlike the COM path).</param>
|
||||
/// <param name="operatorFullName">Operator full name (carried natively, unlike the COM path).</param>
|
||||
/// <param name="password">Operator password for a signed/secured ack, or null.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge whatever is currently SELECTED in the list (a4 = Selected)
|
||||
/// via <see cref="WnalUserRequest"/>. Use when selection has already been
|
||||
/// established on the list rather than passing explicit handles.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocate a zeroed request record (header + room for the string heap),
|
||||
/// stamp recSize + version, then let <see cref="WnalReqSetText"/> pack the
|
||||
/// operator/comment text. Returns an unmanaged buffer the caller must free.
|
||||
/// </summary>
|
||||
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.
|
||||
// ---------------------------------------------------------------------
|
||||
}
|
||||
Reference in New Issue
Block a user