Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38fbbde191 | |||
| 4ea3672b1e | |||
| 069972aff0 | |||
| 6c2d238e20 | |||
| 7618cafb6c | |||
| 488649ce99 | |||
| 02228b2717 | |||
| fb20997dc9 | |||
| 463748e395 | |||
| bd82036d5f | |||
| f13950e453 | |||
| 219611c821 | |||
| a09fa0a4d6 | |||
| 3e988ede88 | |||
| e7f56622c1 | |||
| 1998ee3e20 | |||
| 027cc7393d | |||
| 7b81cb7f72 | |||
| 0143591858 | |||
| e8ace39418 | |||
| 9907a97cb0 | |||
| 84e04b6552 | |||
| 417a47bdb5 | |||
| cfe9f817a3 | |||
| 9409488008 | |||
| 2858e42e80 | |||
| d6db0ff40d | |||
| 0d4b2aea2d | |||
| 8199553734 | |||
| 7900df3dce | |||
| 76db822df3 | |||
| 64dc1215fd | |||
| a94b502ce6 | |||
| bf4b9c6c09 | |||
| 91bbdbf154 | |||
| 8c095b9a56 | |||
| 6c82de55c6 | |||
| 6f4c7c3bf1 | |||
| 6affbd57ea | |||
| 6fc423f608 | |||
| 83aa76c33d | |||
| d779daf6af | |||
| 42c120aa83 | |||
| 19920b13da | |||
| a99070005f | |||
| f40409a688 | |||
| f2e0861709 | |||
| 4f931e4b7e | |||
| d16ae4e267 | |||
| 74ba889dea | |||
| 5776021970 | |||
| a11786cbea | |||
| aea2c74fdf | |||
| 8b147a32a2 | |||
| cc143b52cf | |||
| ad79238c84 | |||
| 986c6c0ce4 | |||
| 484028e721 | |||
| 1b11e89f29 | |||
| 5cdb3da8c0 | |||
| feadcf5740 | |||
| d6c5a11ae6 | |||
| f3827c71c9 | |||
| 1875426e4b | |||
| 780720165a | |||
| b0156c685e | |||
| f2090fe1b5 | |||
| 92aae28f07 | |||
| 4614080e24 | |||
| 8e30ec5be9 | |||
| 30548da71c | |||
| 914495a9f3 | |||
| fa364ce139 | |||
| f2ab9cc879 | |||
| cb820bb666 | |||
| c6650c156b | |||
| 193221773d | |||
| 06cd8b844c | |||
| cfbc99581b | |||
| ab41362a5c | |||
| b27035b3a6 | |||
| 9f0c3fd9fa | |||
| a71663a2a2 | |||
| 8257a0333e | |||
| 2ef7b93761 | |||
| b177636d7a | |||
| 282a0e70ad | |||
| 3619152b9f | |||
| ee68cc3f72 | |||
| 7200023bcc | |||
| 058d8d529f | |||
| aa07497cba | |||
| f1353dda86 | |||
| 3679e59eed | |||
| f59c34a544 | |||
| 20a3eefb1e | |||
| 22d5d52c14 | |||
| 93edb1b74d | |||
| ddfd41b06d | |||
| 7c5eb062e3 | |||
| b75f8b1704 | |||
| 6c784e3db7 |
+23
-6
@@ -4,16 +4,16 @@
|
||||
# .NET Framework 4.8 / x86 and need MXAccess COM installed, so they build ONLY on a Windows host.
|
||||
# They are out of scope for this Linux `portable` job — the `windows-x86` job below builds and
|
||||
# tests them on windev (10.100.0.48) over SSH (see scripts/ci/run-windev-ci.sh), and the nightly
|
||||
# `nightly-windev` job additionally runs the live-MXAccess smoke, which is scheduled-only so it
|
||||
# never gates a push.
|
||||
# `nightly-windev` job additionally runs the full solution build, gateway suite, and live-MXAccess
|
||||
# smoke, which are scheduled-only so they never gate a push.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
# 06:00 UTC nightly: the `nightly-windev` job re-runs the x86 Worker build + full Worker.Tests on
|
||||
# windev, then the live-MXAccess smoke (MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1) and a full-slnx build.
|
||||
# 06:00 UTC nightly: x86 Worker build + Worker.Tests, full-slnx build, gateway tests, then the
|
||||
# live-MXAccess smoke (MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1), serialized on windev's host lock.
|
||||
# Push/PR runs skip it — the job is gated `if: github.event_name == 'schedule'`.
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
@@ -23,7 +23,12 @@ jobs:
|
||||
# tests, the codegen/descriptor freshness guards, and the clients that build on Linux.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# fetch-depth: 0 (full history + tags): the CLI-46 client-version-freshness check below
|
||||
# diffs each client's source against its last publish tag (e.g. clients/go/vX.Y.Z), which
|
||||
# a shallow, tagless checkout cannot resolve.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@@ -77,6 +82,18 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./scripts/check-codegen.ps1
|
||||
|
||||
# CLI-46: catches a client whose source changed since its last publish
|
||||
# tag while the version constant still reads that tag's already-shipped
|
||||
# number — the drift the pack-clients.ps1/tag-go-module.ps1 collision
|
||||
# guards only catch at actual publish time. Needs tag history, hence
|
||||
# `fetch-depth: 0` on this job's checkout above.
|
||||
- name: Client version freshness
|
||||
shell: pwsh
|
||||
run: ./scripts/check-client-versions.ps1
|
||||
|
||||
- name: Code-reviews README consistency
|
||||
run: python code-reviews/regen-readme.py --check
|
||||
|
||||
- name: Gateway fake-worker tests
|
||||
run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj -c Release --no-build
|
||||
|
||||
@@ -165,7 +182,7 @@ jobs:
|
||||
run: ./scripts/ci/run-windev-ci.sh test
|
||||
|
||||
nightly-windev:
|
||||
# TST-25: scheduled deep run — full Worker.Tests + live-MXAccess smoke + full-slnx build on windev.
|
||||
# TST-35: scheduled deep run — Worker, full-slnx, gateway, and live-smoke tiers on windev.
|
||||
# Gated to the schedule event via `if:` (not an unsatisfiable `runs-on`), so push/PR runs mark it
|
||||
# skipped, not queued. Never gates a push. On failure it opens a Gitea issue so a red nightly is
|
||||
# visible even though nobody watches the Actions page.
|
||||
@@ -181,7 +198,7 @@ jobs:
|
||||
PUBLIC_SERVER_URL: https://gitea.dohertylan.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Full Worker.Tests + live-MXAccess smoke on windev
|
||||
- name: Worker + gateway tests + live-MXAccess smoke on windev
|
||||
env:
|
||||
# WINDEV_SSH_KEY is stored base64-encoded (single line) so Gitea's line-oriented secret
|
||||
# masker redacts it in the step env echo; run-windev-ci.sh decodes it. A raw multiline PEM
|
||||
|
||||
@@ -155,3 +155,4 @@ generated-scratch/
|
||||
|
||||
# Agent worktrees (subagent isolation) — never commit
|
||||
.claude/worktrees/
|
||||
.worktrees/
|
||||
|
||||
@@ -49,7 +49,18 @@ mxaccessgw changes here — remote/push status, the `.proto` contracts (`mxacces
|
||||
`mxaccess_worker.proto`, `galaxy_repository.proto`), the two-process architecture, shared-lib
|
||||
consumption, or per-project commands — update the **MxAccessGateway entry in
|
||||
[`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index
|
||||
never drifts from this repo. (Mirrors the same rule in the peer repos.)
|
||||
never drifts from this repo. (Mirrors the same rule in the peer repos.) **Keep the index row to
|
||||
a few lines** — since 2026-09-03 the umbrella keeps only short summary rows and parks long
|
||||
per-project narrative in `../scadaproj/.claude/skills/sister-project-history/<project>.md`
|
||||
(create `mxaccessgateway.md` there if this gateway ever needs one) and shared-library history
|
||||
in `../scadaproj/<lib>/CLAUDE.md`.
|
||||
|
||||
> ⛔ **Wonder box remote access (2026-09-11): no `psbridge`, no `winrm`.** Both skills are retired/disabled —
|
||||
> the WinRM remote-exec channel set off the security software on the wonder boxes (vd03/vq03/vq04). Do not
|
||||
> run remote commands (service control, publish/swap, PowerShell) on those boxes and do not stand up a
|
||||
> replacement channel until the user clears one. Still allowed: UNC/SMB file copies and logging directly
|
||||
> into the SQL Servers. Deploy steps that need a command on the box are blocked; see the umbrella
|
||||
> [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md) for the current hold.
|
||||
|
||||
## Build, Test, Run
|
||||
|
||||
@@ -115,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.
|
||||
@@ -157,9 +168,9 @@ External analysis sources referenced by design docs:
|
||||
|
||||
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`.
|
||||
|
||||
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
|
||||
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `events:read` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `events:read`-scoped key cannot attach to another key's retained session.
|
||||
|
||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`; untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with no tag claims (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`; `/hubs/alarms` and the `/alarms` page additionally require a **real authenticated dashboard role** (`MxGateway.Dashboard.Alarms`, a requirement type the environment-bypass handler does not handle), because the alarm feed is gateway-central with no per-session provenance and so cannot be tag-scoped — its metadata stays galaxy-wide for authenticated Viewers, but anonymous localhost and `Authentication:Mode=Disabled` do not receive it at all. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`. That grant is **re-resolved from the caller's LDAP group claims against current options on every subscribe** (not read from the tag claims stamped at login, which would outlive a config-revoked grant for the cookie's 8-hour sliding life); login-stamped tag claims are the fallback only for a principal carrying no group claims — i.e. the hub-token principal, whose grant the mint already re-resolved. Untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with neither claim kind (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD, session Close/Kill, or the alarm feed (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||
|
||||
## Process / Platform Notes
|
||||
|
||||
|
||||
+9
-2
@@ -79,6 +79,12 @@ Add one entry per finding to the `## Findings` section of the module's
|
||||
|
||||
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
|
||||
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
|
||||
The `GWC`/`WRK`/`IPC`/`SEC`/`CLI`/`TST` prefixes are reserved for the
|
||||
`archreview/` domain registers (a separate, whole-repo review series with its
|
||||
own tracking documents) — never assign one of these prefixes to a
|
||||
`code-reviews/` finding, and any other work stream (perf passes, ad hoc
|
||||
remediation plans, etc.) that wants a stable per-item ID must mint its own
|
||||
distinct prefix rather than borrowing one of these six.
|
||||
- **Severity:**
|
||||
- **Critical** — data loss, security breach, crash/deadlock, or outage.
|
||||
- **High** — incorrect behaviour with significant impact; no safe workaround.
|
||||
@@ -127,8 +133,9 @@ finding carries an unrecognised Status value. The PowerShell wrapper
|
||||
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
|
||||
for CI or a pre-commit step.
|
||||
|
||||
> The repo's installed `python` is the real interpreter; the bare `python3`
|
||||
> alias resolves to the Windows Store stub and fails. Use `python`.
|
||||
> On Windows, the installed interpreter may be available only as `python`
|
||||
> because `python3` can resolve to the Windows Store stub. macOS and Linux
|
||||
> normally use `python3`; CI uses the configured `python` executable.
|
||||
|
||||
The per-module `findings.md` files are the source of truth; `README.md` is the
|
||||
aggregated index and must always agree with them — which the script guarantees.
|
||||
|
||||
@@ -79,6 +79,9 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| WRK-26 | Low | P1 | S | WRK-23 (soft); discharges IPC-29 | Done | Write-priority and overflow doc drift from the WRK-07 change |
|
||||
| WRK-27 | Low | — | S | — | Done | Alarm poll bypasses the watchdog's in-flight suppression (15 s vs 75 s) |
|
||||
| WRK-28 | Low | — | S | WRK-21 (same batch) | Done | 10,000 drain cap is a duplicated magic constant |
|
||||
| WRK-29 | — | — | — | — | Reserved (do not reuse) | Interim ID space possibly burned alongside WRK-31 by `e913dab`'s ad hoc numbering; not confirmed in the repo or its history, held back defensively |
|
||||
| WRK-30 | — | — | — | — | Reserved (do not reuse) | Interim ID space possibly burned alongside WRK-31 by `e913dab`'s ad hoc numbering; not confirmed in the repo or its history, held back defensively |
|
||||
| WRK-31 | — | — | — | — | Reserved (do not reuse) | Interim ID burned by `e913dab` (session-owned transport disposal), cited in code at `Ipc/WorkerPipeSession.cs:45,177` and `Ipc/WorkerPipeSessionTests.cs` outside the register; not a finding — registered per cycle-3 `20-worker.md` (~line 9) so it is never reassigned. Cycle-3 new findings start at WRK-32. |
|
||||
|
||||
### Contracts & IPC — [30-contracts-ipc.md](30-contracts-ipc.md)
|
||||
|
||||
@@ -92,7 +95,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| IPC-28 | Low | — | S | — | Done | docs/Grpc.md missing CommandTooLarge → ResourceExhausted mapping |
|
||||
| IPC-29 | Low | — | S | WRK-26 (discharged by) | Done (discharged by WRK-26) | WorkerFrameProtocol.md missing write-scheduling/sequencing section |
|
||||
| IPC-30 | Low | P0 | M | WRK-21 (same batch) | Done | Oversized event frame: keep session-fatal, make the death structured |
|
||||
| IPC-31 | Info | — | — | — | N/A | Gateway creation-time sequence stamping accepted; diagnostic-only, decision recorded |
|
||||
| IPC-31 | Info | — | — | — | Done (superseded by GWC-28) | Gateway creation-time sequence stamping — resolved when GWC-28 moved gateway stamping to write time; the two sides now implement the same semantics |
|
||||
| IPC-32 | Info | — | S | IPC-25 (folded in) | Done | check-codegen banner relabel 1/4…4/4 |
|
||||
|
||||
### Security & dashboard — [40-security-dashboard.md](40-security-dashboard.md)
|
||||
@@ -152,6 +155,14 @@ Sequence these together rather than piecemeal — several are one change set spa
|
||||
- ~~When CLI-38 lands, close old **CLI-08** with a pointer here.~~ Done 2026-08-07: CLI-38 landed and old CLI-08 is now `Done` in the first-cycle tracker, pointing at [CLI-38](50-clients.md#cli-38--align-netgojava-on-hresult--0-lands-prior-cli-08-cures-the-doc-drift---medium--p1).
|
||||
- ~~When WRK-26 lands, its doc section also discharges the WorkerFrameProtocol gap~~ Done 2026-08-07: WRK-26 landed and **IPC-29** is discharged, pointing at [WRK-26](20-worker.md#wrk-26--write-priority-and-overflow-doc-drift-from-the-wrk-07-change---low--p1). ~~When TST-25 lands, revisit old **TST-05** (scheduled live smoke) and **TST-24** (client wire tests), which it unlocks.~~ TST-05 revisited 2026-08-10: `Partially done` in the first-cycle tracker — the `nightly-windev` job closes the scheduled-cadence half, but the finding's coverage-audit half stays open (the live suite reaches all six late-added COM commands and none of the five control commands). TST-24 revisited in the same change and closed `Done`: Go/Rust/Java already had real-server wire tests, and the two genuine gaps (.NET, Python) now have them plus a CI step.
|
||||
|
||||
## 2026-08-25 documentation correction
|
||||
|
||||
The 2026-08-07 IPC-23 change-log entry's `docs/Grpc.md` deviation rationale is inaccurate:
|
||||
`DrainEvents` is not private worker IPC. It is a public `MxCommandKind` accepted by the
|
||||
`Invoke` RPC. Cycle-3 IPC-38 adds the missing public count-cap, byte-cap,
|
||||
drain-until-empty, and destructive-stream interaction contract to `docs/Grpc.md` while
|
||||
preserving the historical entry below.
|
||||
|
||||
## Change log
|
||||
|
||||
| Date | Change |
|
||||
|
||||
@@ -24,6 +24,7 @@ members, no positional records). The worker builds and tests only on the Windows
|
||||
| WRK-26 | Low | P1 | S | WRK-23 (soft — sequence prose); discharges IPC-29 | Done | Write-priority and overflow doc drift from the WRK-07 change |
|
||||
| WRK-27 | Low | — | S | — | Done | Alarm poll bypasses the watchdog's in-flight suppression (15 s vs 75 s) |
|
||||
| WRK-28 | Low | — | S | WRK-21 (land in the same commit cluster) | Done | 10,000 drain cap is a duplicated magic constant with a comment-only sync contract |
|
||||
| WRK-31 | — | — | — | — | Reserved (do not reuse) | Not a finding. `e913dab` (session-owned transport disposal) coined "WRK-31" ad hoc in code comments (`Ipc/WorkerPipeSession.cs:45,177`) and in `Ipc/WorkerPipeSessionTests.cs`, before the register assigned it. WRK-29/WRK-30 appear nowhere in the repo or its history but may have been burned by the same interim numbering, so they are held back defensively too — see the tracker's WRK-29/30/31 rows. Registered here per cycle-3 `archreview/2026-08-19/20-worker.md` (~line 9, "Finding-ID note"); that review's new findings start at **WRK-32** to guarantee no collision. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ The published-side collection (`CollectPublishedSymbols`, `:61-79`) grows matchi
|
||||
|
||||
**Disposition: N/A — no action.** `gateway.md:328-330` already declares sequence a per-sender diagnostic aid, never validated; both validators ignore it; the divergence is harmless and now explicitly documented by the IPC-29 doc section (which states the two sides' stamping points). If sequence ever becomes load-bearing, stamp in the gateway write loop (`WorkerClient.cs:390-397`) — recorded here so the next editor finds the decision.
|
||||
|
||||
**Resolution: Done — superseded by GWC-28 (2026-08-20, per cycle-3 verification).** GWC-28 moved the gateway-side stamp from creation to the point of writing: `WriteLoopAsync` now stamps `Sequence` immediately before `_writer.WriteAsync` (`Server/Workers/WorkerClient.cs:465-471`), and `CreateEnvelope` leaves it unset (`:1229-1236`); `gateway.md:425-431` was rewritten to "both sides stamp at the point of writing", closing the divergence this finding recorded. Regression test `WorkerClientTests.ConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire` covers it. Per `archreview/2026-08-19/30-contracts-ipc.md` (IPC-31 row), tracker flipped from `N/A` to `Done (superseded by GWC-28)`. See cycle-3 **IPC-39** for the one interaction the change created with the IPC-03 pre-check.
|
||||
|
||||
---
|
||||
|
||||
## IPC-32 — `check-codegen.ps1` check labels are miscounted `Info` · `—` (rides IPC-25)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# MxAccessGateway — Remediation Plan & Tracking (2026-08-19 review)
|
||||
|
||||
Source review: [`../00-overall.md`](../00-overall.md) (HEAD `8b3e3b3`, committed as `f4919ba`). Generated 2026-08-20.
|
||||
Tracks the 34 cycle-3 IDs (GWC-31+, WRK-32+, IPC-33+, SEC-37+, CLI-46+, TST-31+) plus the tracker-housekeeping actions the overall asked for. Cycle-1/2 trackers remain the record for their own IDs.
|
||||
|
||||
**Status legend:** `Not started` · `In progress` · `Done` · `Blocked (external)` · `Deferred (P2)` · `N/A`.
|
||||
|
||||
## Execution model
|
||||
|
||||
This plan is executed as **seven parallel batches**, each by one subagent working in an isolated git worktree on its own `fix/c3-<batch>` branch, merged back into `main` by the coordinator in the order listed (lowest conflict surface first). Every batch must: build the affected component(s) on macOS (`NonWindows.slnx` — the x86 worker cannot build here), run only the filtered tests it touches, update docs in the same commit, and cite finding IDs in commit messages (`fix(IPC-34): …`). Worker-side changes (batches E, F) are verified afterwards on windev via `windev-worker-ci.ps1 -Mode test`.
|
||||
|
||||
**Model assignment rationale:** `opus` for batches that change concurrency/authorization logic or require reasoning across several interacting files with regression tests; `sonnet` for contained, well-specified edits with a clear recipe (version bumps, doc reconciliation, a two-line worker fix); `haiku` for none — every batch either touches code invariants or needs judgement about stale prose, where the cost of a wrong mechanical sweep exceeds the saving.
|
||||
|
||||
| Batch | Branch | Findings | Model | Why this model | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| A | `fix/c3-client-versions` | CLI-46 | sonnet | Mechanical five-client bump + README note + one script guard; recipe is fully specified | Done |
|
||||
| B | `fix/c3-glauth-doc` | TST-31 | sonnet | Reconcile a table against one TOML source of truth, re-derive one default, change an assert to a log | Done |
|
||||
| C | `fix/c3-tracker-hygiene` | GWC-31 + housekeeping (close GWC-16/IPC-08/IPC-31, refresh TST-06/TST-17, register WRK-31) | sonnet | Cross-file comment/ID sweep needs care about what each ID *means*, but no logic changes | Done (`3619152`) |
|
||||
| D | `fix/c3-dashboard-acl` | SEC-40, SEC-41, GWC-38 | opus | Authorization semantics, fail-closed decisions, claims re-resolution; each needs a regression test | **Done** (`b177636` GWC-38, `2ef7b93` SEC-41+SEC-40, `8257a03` comment fixup; branch not merged/pushed) |
|
||||
| E | `fix/c3-ipc-boundaries` | IPC-33, IPC-39, IPC-34, IPC-35 | opus | Changes the WorkerClient command lifecycle, the options validator, and the worker drain budget; race-sensitive | **Done** (`577a0db` IPC-33, `a71663a` IPC-39, `b27035b` IPC-34, `ab41362` IPC-35; branch `fix/c3-ipc-boundaries`, not merged/pushed) — worker half verified on windev (`f2ab9cc`, Worker.Tests 524/524) |
|
||||
| F | `fix/c3-worker-residuals` | WRK-32, WRK-33 (+WRK-34 opportunistic) | sonnet | Small, precisely located worker edits + doc prose; cannot be built on macOS so the agent's job is a careful, minimal diff | Done (windev x86 build + Worker.Tests 524/524 green on `f2ab9cc`) |
|
||||
| G | `fix/c3-session-lifecycle` | GWC-32, GWC-34, GWC-37 | opus | Distributor/dispose lifecycle races and an alarm-monitor re-resolve path; needs lock-discipline reasoning | Done (`058d8d5`, `282a0e7`, `1932217`) |
|
||||
|
||||
**Out of scope for this execution (recorded, not dropped):**
|
||||
|
||||
| ID | Status | Why |
|
||||
|---|---|---|
|
||||
| TST-32 | Blocked (external) | Gitea runner registration-token reset is a web-UI action on the Gitea admin page; no repo change. Operator to-do. |
|
||||
| SEC-37 | Blocked (external) | Family-wide: TLS on the shared GLAuth lives in `scadaproj/infra/glauth/` (separate repo + docker host). Plan separately; after it lands, move the deployed service to `Production`. |
|
||||
| CLI-47/48/49/50, IPC-36/37, TST-35 | Not started | Activated by the 2026-08-25 P2 continuation below. |
|
||||
| GWC-33/35, SEC-38/39 | Done | Closed by continuation batch I; regression counts and commits are recorded below. |
|
||||
| GWC-36, IPC-38, TST-33/34/36 | Done | Closed by continuation batch H; verification and commit are recorded below. |
|
||||
| WRK-34 | Done | Completed opportunistically in batch F (`3679e59`): watchdog faults use `CommandMethod=Watchdog` and carry the stuck correlation ID in the diagnostic message. Tracker correction recorded by the continuation audit. |
|
||||
|
||||
## Batch specifications
|
||||
|
||||
### A — CLI-46 client release-train bump (sonnet)
|
||||
- Bump .NET/Go/Python/Rust clients `0.2.0 → 0.2.1`, Java `0.2.1 → 0.2.2` in every version-bearing file (`pyproject.toml` + `version.py`, `Cargo.toml` workspace + any `version =` consumers, `Directory.Build.props`/csproj `<Version>`, Java `build.gradle`/`gradle.properties`, Go module is tag-driven — update any `Version` const if one exists). Follow `docs/ClientPackaging.md` for the authoritative file list.
|
||||
- `clients/python/README.md` + `docs/ClientPackaging.md`: note that published pypi 0.2.0 has a broken `connect()` (event-loop binding, fixed by `a8f86b5`) and is superseded by 0.2.1.
|
||||
- Add a PR-time check to `scripts/check-codegen.ps1` (or a new `scripts/check-client-versions.ps1` wired where the other checks run): fail if any `clients/<lang>` source differs from its last publish tag while the version constant still equals that tag's version. Document in `docs/ClientPackaging.md`.
|
||||
- Verify: `dotnet build clients/dotnet/...slnx`; `cargo check --workspace`; `python -m pytest` (clients/python); `JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` then **revert** spurious generated-proto churn; `go build ./... && go test ./...`.
|
||||
|
||||
### B — TST-31 glauth.md + live-test credential (sonnet)
|
||||
- Reconcile `glauth.md` against `~/Desktop/scadaproj/infra/glauth/config.toml`: ReadOnly gid, the pre-provisioned users table (drop `readonly/readonly123`, fix `admin` password), the worked example at `:124`. Keep the OPC-UA taxonomy note.
|
||||
- Re-derive `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default in `docs/GatewayTesting.md` and the `?? "admin123"` literal in `WorkerLiveMxAccessSmokeTests.cs` from the config; make the test **log** (not assert) whether `AuthenticateUser` succeeded.
|
||||
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` (IntegrationTests compiles on mac? if excluded, note it).
|
||||
|
||||
### C — GWC-31 + tracker housekeeping (sonnet)
|
||||
- Sweep code comments citing unregistered `GWC-30`/`GWC-31` (perf pass) in `WorkerClient.cs`, `GatewayMetrics.cs`, `WorkerClientTests.cs`, worker `WorkerFrameReader/Writer.cs` to `PERF-nn` keyed to task numbers in `docs/plans/2026-08-15-perf-review-remediation.md`; add a PERF-ID table to that plan. Do not touch the genuine GWC-30 (frame-reader prefix) citations if any exist.
|
||||
- `archreview/REVIEW-PROCESS.md` (or wherever the ID rule lives): state that GWC/WRK/IPC/SEC/CLI/TST prefixes are reserved for archreview registers.
|
||||
- Cycle-2 tracker (`archreview/2026-07-12/remediation/00-tracking.md` + domain docs): GWC-16 → Won't fix (rationale from cycle-3 10-gateway-core.md), IPC-08 → Done (`7c1ea12`, residual IPC-34), IPC-31 → Done (superseded by GWC-28), refresh TST-06/TST-17 rows, register WRK-31 (interim ID burned by `e913dab`) so it is never reused.
|
||||
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`; `dotnet test --filter FullyQualifiedName~WorkerClientTests`.
|
||||
|
||||
### D — SEC-40, SEC-41, GWC-38 (opus)
|
||||
- SEC-41: `DashboardSessionAcl` re-resolves tags from `LdapGroupClaimType` claims via `DashboardGroupTagMapping.MapGroupsToTags` against current options (as `HubTokenService` does) instead of trusting login-stamped tag claims; keep stamped claims only as fallback when no group claims exist (document). Test: principal with stale tag claim + group whose grant was removed → denied.
|
||||
- SEC-40: decide **tighten**: `AlarmsHub` gets a dedicated policy requiring a real role claim (anonymous-loopback/Disabled bypass excluded) — and document in `docs/GatewayDashboardDesign.md` ACL section + `docs/GatewayConfiguration.md` GroupToTag row that alarm metadata remains galaxy-wide for authenticated Viewers (central monitor, no per-session provenance). Test for the policy.
|
||||
- GWC-38: `GatewayBrowseScopeProvider` throws `RpcException(PermissionDenied)` (and logs) when `Current` is null **and** API-key auth is enabled; unchanged when auth mode is Disabled. Test both branches.
|
||||
- Verify: build Server; `dotnet test --filter "FullyQualifiedName~DashboardSessionAcl|FullyQualifiedName~AlarmsHub|FullyQualifiedName~BrowseScope"` (adjust to real class names).
|
||||
|
||||
**Outcome (done, branch `fix/c3-dashboard-acl`, commits `b177636`, `2ef7b93`, `8257a03`; not merged, not pushed).** Decisions worth carrying forward:
|
||||
|
||||
- **SEC-41 fallback:** the ACL re-resolves from `mxgateway:ldap_group` claims when the principal has any, and falls back to the stamped `zb:dashboardtag` claims only when it has none. That is exactly the hub-token principal — `HubTokenService.Validate` rehydrates roles and tags but **not** groups, so re-resolution already happened at mint. The fallback cannot re-open the cookie gap, because `DashboardAuthenticator` stamps both claim kinds from one group list.
|
||||
- **SEC-40 requirement type:** new `AuthenticatedDashboardRoleRequirement` + `AuthenticatedDashboardRoleHandler` and a `MxGateway.Dashboard.Alarms` policy. `DashboardAuthorizationHandler` is `AuthorizationHandler<DashboardAuthorizationRequirement>`, so it is never invoked for the new type — the same structural device the Secrets UI's `secrets:manage` already relies on. Both hub schemes are retained on the policy.
|
||||
- **SEC-40 scope answer:** the `/alarms` Blazor page carries the SAME policy (it reads the central monitor in process — gating only the hub left the disclosure one navigation away), and the side-rail Alarms link is wrapped in an `AuthorizeView` on that policy. `AlarmsHubPublisher` is unchanged and stays unfiltered by design: audience is decided at hub admission and the feed carries no per-session provenance.
|
||||
- **GWC-38:** `PermissionDenied` + warning only when `Authentication:Mode=ApiKey`; `Mode=Disabled` still returns unscoped. `GalaxyRepositoryHostWiringTests` phase 1 was changed to push an *unconstrained identity* rather than none — that is production's real unscoped case.
|
||||
- New/changed tests: `AlarmFeedAuthorizationTests` (new, 7), `DashboardSessionAclTests` (+5), `GatewayBrowseScopeProviderTests` (+2), `SecretsNavRenderTests` (+2, and it must register the new policy — an unregistered policy name *throws* inside `AuthorizeView` rather than denying). Filtered run over the touched classes: 92 passed / 0 failed; `NonWindows.slnx` builds zero-warning.
|
||||
|
||||
### E — IPC-33, IPC-39, IPC-34, IPC-35 (opus)
|
||||
- IPC-33 option (a): `GatewayOptionsValidator` adds the upper coupling `Worker:MaxMessageBytes ≤ Protocol:MaxGrpcMessageBytes + 2×reserve`; worker `ResolveDrainReplyByteBudget` becomes `MaxMessageBytes − 2×DrainReplyFrameHeadroomBytes` (floor `MaxMessageBytes/2`); update `docs/GatewayConfiguration.md`, `docs/WorkerFrameProtocol.md`. Test: `GatewayOptionsTests` case for the new bound.
|
||||
- IPC-39: pre-check compares against `MaxMessageBytes − MaxSequenceEncodingBytes (11)`; fix the comment direction.
|
||||
- IPC-34: caller-cancellation arms call `TrySendCancelForAbandonedCommand` (renamed) gated on `removed`, skipped when `_stopCts` is the source; new `WorkerClientErrorCode.CallerCancelled` replacing the `GatewayShutdown` mislabel, mapped to `Cancelled` in the gRPC layer; update `docs/Grpc.md` mapping line, `gateway.md:913-919`, `docs/GatewayProcessDesign.md:596-602`. Test: twin of the timeout test driven by the caller token.
|
||||
- IPC-35: `ReadHandshakeEnvelopeAsync` special-cases `WorkerFault` → `WorkerClientException(WorkerFaulted, CreateWorkerFaultMessage(...))`. Test: fake writes `WorkerFault{MxaccessCreationFailed}` instead of `WorkerReady`, category text surfaces.
|
||||
- Verify: build Server; `dotnet test --filter "FullyQualifiedName~WorkerClientTests|FullyQualifiedName~GatewayOptions"`. Worker half → windev afterwards.
|
||||
|
||||
### F — WRK-32, WRK-33 (sonnet)
|
||||
- WRK-33: `ObserveFaultWhenever(ObserveCommandTaskAsync(commandTask));` at `WorkerPipeSession.cs:1235` (or catch-all-and-log in the observer).
|
||||
- WRK-32: one `pumpStep()` before capturing the `CurrentVersion` baseline in `MxAccessCommandExecutor.cs`; rewrite the executor doc comment (`:996-1003`) to state the recency-based contract; add the same to `docs/MxAccessWorkerInstanceDesign.md` write-completion section and the reply-field comment in `mxaccess_gateway.proto` (**comment-only proto edit → regenerate all generated artifacts in the same commit**, per the repo rule; if regen tooling is unavailable on macOS, skip the proto comment and say so).
|
||||
- WRK-34 if trivial: `CreateFault(StaHung, "Watchdog", …)` with the correlation id moved into the message.
|
||||
- Cannot build on macOS: keep diff minimal, net48-safe (no init-only/positional records). Verified on windev afterwards.
|
||||
|
||||
### G — GWC-32, GWC-34, GWC-37 (opus)
|
||||
- GWC-32: `DisposeAsync` transitions `_state` to `Closed` under `_syncRoot` before completing channels (or `if (_disposed || IsTerminalState()) return;` in `StageWorkerEvent`); update the XML doc to name all three completion paths. Test: dispose a Ready client mid-dispatch → no `ProtocolViolation` fault/metric.
|
||||
- GWC-34: `_eventDistributorDisposed` flag set under `_syncRoot` when nulling; `EnsureDistributorCreated` throws `SessionManagerException(SessionNotReady)` when set. Test: attach after dispose → throws, no silent hang.
|
||||
- GWC-37: on `ApplyProviderModeChangeAsync(toMode == Subtag)` re-resolve the watch list and re-subscribe; additionally surface "zero targets resolved while mode can need them" on the provider status (`Degraded` + reason). Update `docs/` alarm-subtag section. Test with the fake worker: GR discovery fails at start, succeeds at failover → watch list non-empty after failover.
|
||||
- Verify: build Server; `dotnet test --filter "FullyQualifiedName~WorkerClientTests|FullyQualifiedName~GatewaySession|FullyQualifiedName~AlarmMonitor|FullyQualifiedName~AlarmWatchList"`.
|
||||
- **Outcome (Done):** `058d8d5` GWC-32, `282a0e7` GWC-34, `1932217` GWC-37 on `fix/c3-session-lifecycle`. Filtered run 176/176 green; `NonWindows.slnx` clean.
|
||||
- GWC-37 **does not re-send** the watch-list on the failover edge. The worker's `AlarmCommandHandler.Subscribe` rejects a second `SubscribeAlarms` while subscribed, and the only expressible replacement (`UnsubscribeAlarms` + `SubscribeAlarms`) rebuilds `FailoverAlarmConsumer` — restarting it on the primary that just failed and discarding the warm standby — so it would flap the feed rather than repair it. The re-resolve happens and any drift is reported on the same status frame as the failover; a monitor restart is the supported way to apply a new list. Adding a watch-list-update worker command is a possible future item, not done here.
|
||||
- GWC-37 changes `IAlarmWatchListResolver.ResolveAsync` to return `AlarmWatchListResolution`, and gives `Degraded && Mode==ALARMMGR` a real meaning (empty/undiscoverable standby) with a new dashboard label `Alarm Manager (no subtag standby)`.
|
||||
- GWC-32's dispose path now also records `WorkerStopped("dispose")` via the existing once-latch, closing a `workers_running` gauge leak on the dispose-a-Ready-client path. GWC-12's double-dispose interlock got a natural home (the `_disposed` claim moved under `_syncRoot`) but is **not** claimed as fixed.
|
||||
|
||||
## Merge order
|
||||
C is merged **last** (its comment sweep touches the same files E and G edit). Order: A, B, F, D, G, E, C. After merge: full `NonWindows` build + one full gateway test run; then windev worker CI for E+F.
|
||||
|
||||
## Integration gate (2026-08-20)
|
||||
All seven branches merged into `main` (A `ee68cc3`, B `f59c34a`, C `f2ab9cc`, D `9f0c3fd`, E `cfbc995`, F `aa07497`, G `c6650c1`). Merged tree: `NonWindows.slnx` 0 warnings; full gateway suite **1183/1183** passed; windev `windev-worker-ci.ps1 -Sha f2ab9cc -Mode test`: x86 Worker build succeeded, `Worker.Tests` **524 passed / 11 skipped / 0 failed**. Follow-ups surfaced by the batches: a worker command to refresh a live alarm subscription's watch-list (would let GWC-37's re-resolve be applied, not just reported); WRK-32's proto reply-field comment deferred until the next proto regen wave; the five clients are bumped but not yet published.
|
||||
|
||||
## P2 continuation (planned 2026-08-25)
|
||||
|
||||
Current-state audit: refreshed `origin/main` and re-checked every deferred finding and integration-gate follow-up. Sixteen of the seventeen P2 findings remain open; WRK-34 was already completed by `3679e59`. All three follow-ups remain open. Exact target client coordinates are still absent from Gitea (HTTP 404), and `clients/go/v0.2.1` does not exist.
|
||||
|
||||
Approved design: [`../../../docs/plans/2026-08-25-cycle-3-p2-followups-design.md`](../../../docs/plans/2026-08-25-cycle-3-p2-followups-design.md) (`30548da`).
|
||||
|
||||
Execution plan: [`../../../docs/plans/2026-08-25-cycle-3-p2-followups.md`](../../../docs/plans/2026-08-25-cycle-3-p2-followups.md).
|
||||
|
||||
The continuation runs serially in the isolated `.worktrees/c3-p2-followups` worktree on `codex/c3-p2-followups`, with a tracker update at every batch boundary. Codex equivalents are selected from the implementation risk: `gpt-5.6-terra`/medium for contained documentation, CI, and client work; `gpt-5.6-sol`/high for concurrency, authorization, protocol, and live alarm lifecycle changes.
|
||||
|
||||
| Batch | Findings / follow-ups | Codex model | Status | Exit gate |
|
||||
|---|---|---|---|---|
|
||||
| H — tracker and currency | WRK-34 record, GWC-36, IPC-38, TST-33/34/36 | `gpt-5.6-terra` medium | Done | Currency greps clean; README check in portable CI; NonWindows build clean |
|
||||
| I — gateway correctness/security | GWC-33/35, SEC-38/39 | `gpt-5.6-sol` high | Done | Focused regression tests green; NonWindows build clean |
|
||||
| J — secondary contracts | IPC-36/37 | `gpt-5.6-sol` high | Done | Shared launch constants and descriptor parity tests green |
|
||||
| K — client alarm parity | CLI-47/48/49/50 | `gpt-5.6-terra` high | Done | All five client suites green |
|
||||
| L — live watch-list/proto wave | watch-list refresh follow-up + WRK-32 proto comment | `gpt-5.6-sol` high | Done | Codegen clean; portable alarm/contract tests green; windev Worker.Tests green |
|
||||
| M — Windows gateway CI | TST-35 | `gpt-5.6-terra` medium | Done | windev `live` mode proves Worker, gateway, full-slnx, and live smoke tiers |
|
||||
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | Done | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
|
||||
|
||||
### Continuation tasklist
|
||||
|
||||
#### H — tracker and currency
|
||||
|
||||
- [x] Correct WRK-34 from Deferred to Done with commit `3679e59`.
|
||||
- [x] GWC-36: correct `EventSubscriberReplayAttachment.OldestAvailableSequence` semantics.
|
||||
- [x] IPC-38: add public `DrainEvents` behavior to `docs/Grpc.md` and correct the cycle-2 omission rationale.
|
||||
- [x] TST-33: reconcile the five stale/self-contradictory `stillpending.md` claims and mark its live-status boundary.
|
||||
- [x] TST-34: run `code-reviews/regen-readme.py --check` in portable CI and qualify the interpreter note.
|
||||
- [x] TST-36: replace prose ``event`` scope references with canonical ``events:read``.
|
||||
- [x] Record batch-H commit and verification evidence here.
|
||||
|
||||
Batch-H verification (2026-08-25): `python3 code-reviews/regen-readme.py --check`
|
||||
reported the generated README current and consistent; the TST-33 and live-document
|
||||
TST-36 stale-pattern greps returned no matches; `docs/Grpc.md` contains the public
|
||||
`DrainEvents` contract; and `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`
|
||||
succeeded with **0 warnings / 0 errors** in 1.59 s. Batch commit: `92aae28`.
|
||||
|
||||
#### I — gateway correctness and security
|
||||
|
||||
- [x] GWC-33: unregister terminal clients from the event-queue-depth gauge without shortening fault retention.
|
||||
- [x] GWC-35: repair acknowledged-to-active snapshot deltas with a Raise transition and instance identity.
|
||||
- [x] SEC-38: feed/consult the aggregate for valid key IDs that overflow the peer partition cap.
|
||||
- [x] SEC-39: prove a null effective key ID cannot reset the shared fallback partition.
|
||||
- [x] Run focused tests and clean NonWindows build; record counts and commits here.
|
||||
|
||||
Batch-I verification (2026-08-25): the combined `WorkerClientTests`,
|
||||
`GatewayAlarmMonitorAttachOrderTests`, and `ApiKeyFailureLimiterTests` filter passed
|
||||
**55/55** tests in 12 s; `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`
|
||||
succeeded with **0 warnings / 0 errors** in 3.42 s. Finding commits: GWC-33
|
||||
`b0156c6`, GWC-35 `7807201`, and SEC-38/SEC-39 `1875426`.
|
||||
|
||||
#### J — secondary contracts
|
||||
|
||||
- [x] IPC-36: centralize all six worker-launch environment names in Contracts and consume them on both sides.
|
||||
- [x] IPC-36: document variable/config/default mapping and test launcher completeness.
|
||||
- [x] IPC-37: add normalized retained-vs-served Galaxy descriptor parity test.
|
||||
- [x] Run focused tests and clean NonWindows build; record counts and commits here.
|
||||
|
||||
Batch-J verification (2026-08-25): the combined `WorkerProcessLauncherTests` and
|
||||
`GalaxyContractParityTests` filter passed **10/10** tests in 1 s; the descriptor
|
||||
mutation proof identified the changed protobuf message; and `dotnet build
|
||||
src/ZB.MOM.WW.MxGateway.NonWindows.slnx` succeeded with **0 warnings / 0
|
||||
errors** in 2.20 s. IPC-36/IPC-37 commit: `d6c5a11`.
|
||||
|
||||
#### K — client alarm-feed parity
|
||||
|
||||
- [x] CLI-47: make .NET text status flags explicit and document deliberate JSON differences.
|
||||
- [x] CLI-48: test provider/snapshot status arms in .NET, Go, and Java; add Go stream-alarm scaffold.
|
||||
- [x] CLI-49: export Go aliases for `AlarmProviderStatus` and `AlarmSnapshotStatus`; fix five-arm documentation.
|
||||
- [x] CLI-50: stream Python alarm rows incrementally with an unbounded default and bounded opt-in.
|
||||
- [x] Run .NET, Go, Python, Rust, and Java client suites; record counts and commits here.
|
||||
|
||||
Batch-K verification (2026-08-25): `dotnet test
|
||||
clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` passed **134/134** portable
|
||||
tests (1 live test skipped); `go test ./...` passed both tested packages;
|
||||
`python -m pytest` passed **169/169** portable tests (1 opt-in TLS test
|
||||
skipped); `cargo test --workspace` passed **108/108** tests; and
|
||||
`JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` passed **132/132** tests.
|
||||
CLI-47/CLI-48/CLI-49/CLI-50 commit: `5cdb3da`.
|
||||
|
||||
#### L — live watch-list refresh and protobuf wave
|
||||
|
||||
- [x] Add explicit fallback-enabled subscription semantics for an initially empty discovered list.
|
||||
- [x] Add and round-trip `UpdateAlarmWatchListCommand` and its `MxCommandKind`.
|
||||
- [x] Route the command through the worker executor and alarm handler on the STA.
|
||||
- [x] Build/subscribe/attach a replacement standby before swap; preserve the old standby on failure.
|
||||
- [x] Preserve current primary/subtag mode during a successful replacement.
|
||||
- [x] Re-resolve on the existing gateway reconciliation cadence and send only material changes/recovery.
|
||||
- [x] Refresh immediately at failover edge, update acknowledged fingerprint only after worker OK, and reconcile after success.
|
||||
- [x] Expand WRK-32's public reply-field comment to the sequential stale-callback/recency case.
|
||||
- [x] Replace IPC-36's internal env-var name in the proto comment with the public configuration key.
|
||||
- [x] Regenerate Contracts and all client generated trees once; run codegen and all affected suites.
|
||||
- [x] Verify x86 Worker build and Worker.Tests on windev; record SHA/counts here.
|
||||
|
||||
Batch-L verification (2026-08-25): `pwsh scripts/check-codegen.ps1` passed all
|
||||
four descriptor/C#/Rust/Go/Python freshness checks, and Java
|
||||
`checkGeneratedClean` passed. The exact portable alarm/contract/descriptor
|
||||
filter passed **77/77**; `NonWindows.slnx` built with **0 warnings / 0 errors**;
|
||||
the .NET, Go, Python, Rust, and Java client suites were green (respectively
|
||||
**134 passed / 1 live skip**, both Go packages, **169 passed / 1 opt-in TLS
|
||||
skip**, **108 passed**, and **132 passed**). Windev `test` mode at
|
||||
`986c6c0ce4f02c62b4f59ec44780de5af6d291b8` built the x86/net48 Worker with
|
||||
**0 warnings / 0 errors** and passed Worker.Tests **529 passed / 11 opt-in
|
||||
skips / 0 failed**. Feature/proto commit: `484028e`; Batch-J x86 compile
|
||||
follow-up: `986c6c0`.
|
||||
|
||||
#### M — Windows gateway CI
|
||||
|
||||
- [x] TST-35: add the serialized net10 gateway suite to windev `live` mode.
|
||||
- [x] Update `docs/GatewayTesting.md` and `scripts/ci/README.md` with order/failure semantics.
|
||||
- [x] Execute `live` mode on windev and record Worker, gateway, full-slnx, and live-smoke evidence here.
|
||||
|
||||
Batch-M verification (2026-08-25): windev `live` mode at exact commit
|
||||
`cc143b52cf04be0b2f08350922a987b2e250bc1d` completed the serialized sequence in
|
||||
**4m45s** with remote exit code 0. The x86/net48 Worker build succeeded with **0
|
||||
warnings / 0 errors** in 22.59 s; Worker.Tests passed **529**, skipped **11**
|
||||
opt-in probes, and failed **0**; the full Windows `slnx` build succeeded with **0
|
||||
warnings / 0 errors** in 43.24 s; the net10 gateway suite passed **1193/1193**
|
||||
in 53 s; and the live-MXAccess smoke passed **8/8** in 1m46s. TST-35 commit:
|
||||
`cc143b5`.
|
||||
|
||||
#### N — integration and release
|
||||
|
||||
- [x] Run `git diff --check`, codegen, client-version, README, NonWindows build, and full gateway suite gates.
|
||||
- [x] Run all five client suites on the final candidate.
|
||||
- [x] Run final windev `live` gate at the exact candidate SHA.
|
||||
- [x] Merge/push the verified continuation to `main`; wait for green CI.
|
||||
- [x] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
|
||||
- [x] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
|
||||
- [x] Create/push `clients/go/v0.2.1` at the exact release commit.
|
||||
- [x] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
|
||||
|
||||
Batch-N portable verification (2026-08-25): `git diff --check`, codegen freshness,
|
||||
client-version freshness, and the generated README check all passed without changing
|
||||
the worktree. `NonWindows.slnx` built with **0 warnings / 0 errors**; the gateway
|
||||
suite passed **1193/1193**, and the portable integration suite passed **5** with
|
||||
**19** live-only skips. The final .NET, Go, Python, Rust, and Java client gates were
|
||||
green (respectively **134 passed / 1 live skip**, all Go packages built/tested with
|
||||
clean `gofmt`, **169 passed / 1 opt-in TLS skip** from a clean virtual environment,
|
||||
**108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the
|
||||
generated Java tree unchanged).
|
||||
|
||||
Batch-N integration/release verification (2026-08-25): `main` was integrated and
|
||||
released at exact commit `5776021970cbbe59813ba57579abe00d9fc61a46`; Gitea CI
|
||||
run **783** concluded success. Windev `live` mode at that SHA built the x86/net48
|
||||
Worker with **0 warnings / 0 errors**, passed Worker.Tests **529 passed / 11
|
||||
opt-in skips / 0 failed**, built the full Windows solution with **0 warnings / 0
|
||||
errors**, passed the Windows gateway suite **1193/1193**, and passed the live
|
||||
MXAccess smoke **8/8**. One preceding full-suite attempt hit a transient fake-worker
|
||||
disconnect in the reconnect/replay test; the test then passed **50/50** isolated
|
||||
macOS runs and **20/20** isolated windev runs before the clean full-gate pass.
|
||||
|
||||
Immediately before publication, all five package coordinates returned HTTP 404
|
||||
and `clients/go/v0.2.1` was absent locally/remotely. The published coordinates are
|
||||
NuGet `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, PyPI
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, Cargo
|
||||
`zb-mom-ww-mxgateway-client` **0.2.1**, and Maven
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. Each exact Gitea
|
||||
package API coordinate returned HTTP 200 after publication. Annotated Go tag
|
||||
`clients/go/v0.2.1` resolves locally, remotely, and through `go list -m` to
|
||||
`5776021970cbbe59813ba57579abe00d9fc61a46`.
|
||||
@@ -74,7 +74,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| GWC-13 | Low | — | M | — | Not started | Worker-ready wait is a 25 ms poll loop |
|
||||
| GWC-14 | Low | P2 | M | — | Done | Replay ring is a `LinkedList` with a node alloc per event |
|
||||
| GWC-15 | Low | P2 | S | — | Done | Per-event gauge reads `ChannelReader.Count` every event |
|
||||
| GWC-16 | Low | — | S | — | Not started | `Invoke` resolves the session twice per command |
|
||||
| GWC-16 | Low | — | S | — | Won't fix | `Invoke` resolves the session twice per command — measured negligible, deliberately not collapsed (see domain doc) |
|
||||
| GWC-17 | Low | — | M | — | Not started | Sessions layer throws `Grpc.Core.RpcException` (layering leak) |
|
||||
| GWC-18 | Low | — | S | — | Not started | `GatewaySession` implements `DisposeAsync` without `IAsyncDisposable` |
|
||||
| GWC-19 | Low | — | S | — | Not started | Dead `GatewaySession.KillWorker(string)` + stale doc |
|
||||
@@ -119,7 +119,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| IPC-05 | Medium | P2 | S | — | Done | Redundant deep copies on command and event hot paths |
|
||||
| IPC-06 | Medium | P2 | S | — | Done | `gateway.md` Worker Envelope sketch no longer matches the contract |
|
||||
| IPC-07 | Medium | P2 | S | — | Done | `docs/Grpc.md` says six RPCs; there are seven |
|
||||
| IPC-08 | Medium | — | M | — | Not started | `WorkerCancel` defined and handled but never sent |
|
||||
| IPC-08 | Medium | — | M | — | Done | `WorkerCancel` defined and handled but never sent — resolved by `7c1ea12`/`a1a38b5`; residual (caller-cancellation leg) tracked as cycle-3 IPC-34 |
|
||||
| IPC-09 | Medium | P1 | M | IPC-01 | Done | Known codegen fragilities have no in-repo guards |
|
||||
| IPC-10 | Low | — | S | — | Not started | Envelope `sequence` is write-only; monotonicity never validated |
|
||||
| IPC-11 | Low | — | S | — | Not started | No protocol version negotiation despite `supported_protocol_version` name |
|
||||
@@ -218,7 +218,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| TST-03 | High | P1 | M | — | Done | No CI exists |
|
||||
| TST-04 | High | P2 | L | — | Done | Session-resilience epic 16/28 tasks unfinished |
|
||||
| TST-05 | Medium | P1 | S | TST-03 | Partially done | Real-worker control/COM paths verified opt-in only. **Scheduling half closed 2026-08-10** by the TST-25 `nightly-windev` job (`.gitea/workflows/ci.yml`, cron `0 6 * * *` → `scripts/ci/run-windev-ci.sh live`, which sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, runs `WorkerLiveMxAccessSmokeTests`, and opens a Gitea issue on failure) — "opt-in, run by memory" is now "runs nightly, reports failures". **Residual: the coverage-audit half.** The live suite's 8 facts cover all six late-added COM commands but none of the five control commands (`Ping`, `GetSessionState`, `GetWorkerInfo`, `DrainEvents`, `ShutdownWorker`), which real workers answer in `Worker/Ipc/WorkerPipeSession.cs` yet are still only exercised through `FakeWorkerHarness` canned replies — precisely the masking the finding named |
|
||||
| TST-06 | Medium | — | M | — | Not started | Dashboard live-data path untested |
|
||||
| TST-06 | Medium | — | M | — | Materially narrowed | Dashboard live-data path untested — `DashboardLiveDataServiceTests`, `EventsHubTests`, `EventsHubViewerRegistryTests`, `AlarmsHubPublisherTests`, `SessionDetailsPageEventAclTests` now exist (landed with SEC-25/TST-16); needs a re-audit-and-close pass, not re-implementation |
|
||||
| TST-07 | Medium | — | S | — | Not started | Real-clock sleeps with negative assertions are latent flakes |
|
||||
| TST-08 | Medium | P1 | M | — | Done | Full-suite orphaned testhost processes (does not reproduce; doc de-stale) |
|
||||
| TST-09 | Medium | — | M | — | Not started | Health checks cover only the auth store |
|
||||
@@ -229,7 +229,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage |
|
||||
| TST-15 | Medium | P2 | M | TST-04 | Done | Dashboard EventsHub has no per-session ACL |
|
||||
| TST-16 | Medium | P1 | S | — | Done | `Dashboard:ShowTagValues` is a dead flag |
|
||||
| TST-17 | Medium | — | S | — | Not started | Vendor-gated alarm parity residuals silently lossy |
|
||||
| TST-17 | Medium | — | S | — | Materially changed | Vendor-gated alarm parity residuals silently lossy — `WnWrapAlarmConsumer.cs:309-327` now passes `szDomainName`/`szOprFullName`; the vendor 8-arg leg is recorded "unavailable by configuration" with evidence (`docs/AlarmProbeFindings.md`, `d1ae43d`, `540a118`); "silent drop, unchanged" is no longer accurate — needs a worker-domain refresh pass |
|
||||
| TST-18 | Low | — | S | — | Not started | Hosted-service wrappers untested |
|
||||
| TST-19 | Low | — | S | — | Not started | Keep FakeWorkerHarness canned replies in lockstep |
|
||||
| TST-20 | Low | — | S | — | Not started | E2E script papers over a real advise-without-consumer sharp edge |
|
||||
|
||||
@@ -330,6 +330,8 @@ Open question the review cannot settle: whether a future probe (COM-init readine
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; `dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter "FullyQualifiedName~SessionManager"`.
|
||||
|
||||
**Resolution: Won't fix (2026-08-20, closed by cycle-3 verification).** `Grpc/MxAccessGatewayService.cs:111-115` carries a `PERF(followup)` comment documenting the double session lookup as measured-negligible and deliberately not collapsed — the fix would duplicate `InvokeAsync`'s fault mapping at a second entry point, trading a negligible per-command cost for a maintenance liability. Per the cycle-3 gateway-core review (`archreview/2026-08-19/10-gateway-core.md`, GWC-16 row), the rationale is sound; tracker flipped from `Not started` to `Won't fix`.
|
||||
|
||||
---
|
||||
|
||||
## GWC-17 — Sessions layer throws `Grpc.Core.RpcException` `Low` · `—`
|
||||
|
||||
@@ -175,6 +175,8 @@ The frame protocol and proto evolution hygiene are sound; the remediable risk cl
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server` + `dotnet test ...Tests --filter FullyQualifiedName~WorkerClient`; worker side `-p:Platform=x86`.
|
||||
|
||||
**Resolution: Done (2026-08-20, closed by cycle-3 verification, residual filed).** Wired up substantially as designed: `InvokeAsync` forwards a `WorkerCancel` when the gateway's own command timeout fires and it owned the removal (`Server/Workers/WorkerClient.cs:269-291`), `TrySendCancelForTimedOutCommand` is structurally no-throw (`:835-870`), the worker routes it to `CancelCommand` (`Worker/Ipc/WorkerPipeSession.cs:755-756` → `MxAccess/MxAccessStaSession.cs:529-532` → `Sta/StaCommandDispatcher.CancelQueuedCommand:151`), regression test `WorkerClientTests.InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation` exists, and `docs/GatewayProcessDesign.md:596-602` documents it. Landed in `7c1ea12`/`a1a38b5`. **Residual, not covered by this closure:** the caller-cancellation leg (context deadline / client disconnect, as opposed to the gateway's own `CommandTimeout`) sends nothing — filed as cycle-3 **IPC-34**. Per `archreview/2026-08-19/30-contracts-ipc.md` (IPC-08 row), tracker flipped from `Not started` to `Done` with the residual cross-referenced.
|
||||
|
||||
---
|
||||
|
||||
## IPC-09 — Known codegen fragilities have no in-repo guards `Medium` · `P1`
|
||||
|
||||
@@ -191,6 +191,8 @@ This finding is the umbrella; TST-01/02/15 are its actionable slices. The remedi
|
||||
|
||||
**Verification.** `dotnet test ... --filter FullyQualifiedName~DashboardLiveDataServiceTests`.
|
||||
|
||||
**Status refresh (2026-08-20, per cycle-3 verification).** Materially narrowed — the tracker row was stale. `Tests/Gateway/Dashboard/DashboardLiveDataServiceTests.cs`, `EventsHubTests.cs`, `EventsHubViewerRegistryTests.cs`, `AlarmsHubPublisherTests.cs`, and `SessionDetailsPageEventAclTests.cs` all exist now (mostly landed with the SEC-25/TST-16 closeouts and the in-process dashboard-feed work). Per `archreview/2026-08-19/60-testing-docs-gaps.md` (TST-06 row), this needs a re-audit-and-close pass against the current coverage rather than fresh implementation — left open pending that audit, tracker status changed from `Not started` to `Materially narrowed` to reflect the actual state.
|
||||
|
||||
---
|
||||
|
||||
## TST-07 — Real-clock sleeps with negative assertions are latent flakes `Medium` · `—`
|
||||
@@ -371,6 +373,8 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
|
||||
|
||||
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Worker -p:Platform=x86`; `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/... -p:Platform=x86` (Windows).
|
||||
|
||||
**Status refresh (2026-08-20, per cycle-3 verification).** Materially changed — the tracker row needs a refresh from the worker domain. `WnWrapAlarmConsumer.cs:309-327` now passes `szDomainName`/`szOprFullName` into the COM call (the `_ = ackOperatorDomain` discard is gone), and the 2026-08 probe cycle recorded the vendor 8-arg leg as "unavailable by configuration" with evidence (`docs/AlarmProbeFindings.md`, commits `d1ae43d`, `540a118`). Per `archreview/2026-08-19/60-testing-docs-gaps.md` (TST-17 row), "silent drop, unchanged" is no longer an accurate description of the row; whether a caller-visible degrade signal now exists is the worker reviewer's call to close out. Tracker status changed from `Not started` to `Materially changed`.
|
||||
|
||||
---
|
||||
|
||||
## TST-18 — Hosted-service wrappers untested `Low` · `—`
|
||||
|
||||
@@ -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.
|
||||
@@ -166,6 +168,11 @@ when the gateway's alarm monitor restarts and drops a truncated verdict. Track
|
||||
it if you need set completeness on a live feed without polling
|
||||
`QueryActiveAlarmsAsync`.
|
||||
|
||||
The CLI's text rows always spell out `degraded=false` and `truncated=false` so
|
||||
healthy and clearing frames remain visible. Its protobuf-JSON modes retain the
|
||||
standard proto3 omission rule, so those same false-valued payloads appear as
|
||||
`{"providerStatus": {}}` and `{"snapshotStatus": {}}`; absent means false.
|
||||
|
||||
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||
the first `CloseSessionReply` instead of sending another close request.
|
||||
|
||||
@@ -285,11 +292,16 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --s
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json
|
||||
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 1 --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 -- 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 --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"))
|
||||
@@ -347,13 +348,19 @@ public static class MxGatewayClientCli
|
||||
|
||||
/// <summary>
|
||||
/// Canonical CLI credential environment variable, shared by every official
|
||||
/// client CLI (CLI-45) so one exported variable drives the same operator
|
||||
/// client CLI so one exported variable drives the same operator
|
||||
/// workflow in all five languages.
|
||||
/// </summary>
|
||||
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
|
||||
|
||||
/// <summary>
|
||||
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
|
||||
@@ -379,14 +386,38 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective MXAccess verify-user credential in the CLI-45
|
||||
/// 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>
|
||||
/// alias, the environment variable named by <c>--password-env</c> (default
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
|
||||
/// treated as absent. The credential is never echoed; this resolver exists so
|
||||
/// the error-redaction catch block can strip it from any surfaced error
|
||||
/// (CLI-04), mirroring <see cref="TryResolveApiKey" />.
|
||||
/// the error-redaction catch block can strip it from any surfaced error,
|
||||
/// mirroring <see cref="TryResolveApiKey" />.
|
||||
/// </summary>
|
||||
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
|
||||
{
|
||||
@@ -416,7 +447,7 @@ public static class MxGatewayClientCli
|
||||
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
|
||||
/// a redaction-safe error when no source yields a non-empty value. Failing
|
||||
/// fast keeps a misconfigured environment from becoming a real MXAccess
|
||||
/// authentication attempt with an empty credential (CLI-45); the thrown
|
||||
/// authentication attempt with an empty credential; the thrown
|
||||
/// message names only the option/env var, never the value.
|
||||
/// </summary>
|
||||
private static string ResolveVerifyUserPassword(CliArguments arguments)
|
||||
@@ -758,9 +789,9 @@ public static class MxGatewayClientCli
|
||||
{
|
||||
// The credential is resolved from --password or its env var (default
|
||||
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
|
||||
// fails fast before the invoke rather than reaching the wire (CLI-45).
|
||||
// fails fast before the invoke rather than reaching the wire.
|
||||
// On any surfaced error the RunCoreAsync catch block routes
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
|
||||
// it through MxGatewayCliSecretRedactor so it cannot reach stderr.
|
||||
return InvokeAndWriteAsync(
|
||||
arguments,
|
||||
client,
|
||||
@@ -1425,7 +1456,7 @@ public static class MxGatewayClientCli
|
||||
else if (gatewayEvent.ReplayGap is { } replayGap)
|
||||
{
|
||||
// Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw
|
||||
// sentinel MxEvent (NEXT-02, mirroring the Go/Python/Rust CLIs).
|
||||
// sentinel MxEvent, mirroring the Go/Python/Rust CLIs.
|
||||
output.WriteLine(FormatReplayGapRow(replayGap));
|
||||
}
|
||||
else
|
||||
@@ -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(
|
||||
@@ -1561,9 +1594,9 @@ public static class MxGatewayClientCli
|
||||
AlarmFeedMessage.PayloadOneofCase.Transition =>
|
||||
$"transition {ProtobufJsonFormatter.Format(feedMessage.Transition)}",
|
||||
AlarmFeedMessage.PayloadOneofCase.ProviderStatus =>
|
||||
$"provider-status {ProtobufJsonFormatter.Format(feedMessage.ProviderStatus)}",
|
||||
$"provider-status mode={feedMessage.ProviderStatus.Mode} degraded={feedMessage.ProviderStatus.Degraded.ToString().ToLowerInvariant()} reason=\"{feedMessage.ProviderStatus.Reason}\"",
|
||||
AlarmFeedMessage.PayloadOneofCase.SnapshotStatus =>
|
||||
$"snapshot-status {ProtobufJsonFormatter.Format(feedMessage.SnapshotStatus)}",
|
||||
$"snapshot-status truncated={feedMessage.SnapshotStatus.Truncated.ToString().ToLowerInvariant()}",
|
||||
_ => $"unknown-payload {feedMessage.PayloadCase}",
|
||||
};
|
||||
}
|
||||
@@ -1849,7 +1882,7 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the
|
||||
/// Formats the typed ReplayGap row shared by the CLIs. Hand-built so the
|
||||
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
|
||||
/// formatter's quoted uint64 strings.
|
||||
/// </summary>
|
||||
@@ -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]
|
||||
|
||||
@@ -134,6 +134,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -178,6 +179,7 @@ public sealed class MxGatewayClientCliTests
|
||||
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
|
||||
/// credential from the flag, and that the credential never appears in stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
|
||||
{
|
||||
@@ -218,9 +220,10 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-04: a surfaced error for authenticate-user must have the credential
|
||||
/// Verifies that a surfaced error for authenticate-user must have the credential
|
||||
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
|
||||
{
|
||||
@@ -248,7 +251,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other
|
||||
/// <c>--password</c> is the primary credential flag, matching the other
|
||||
/// four CLIs. The credential reaches the wire but never stdout/stderr.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
@@ -290,10 +293,12 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: the credential is read from the environment variable named by
|
||||
/// the credential is read from the environment variable named by
|
||||
/// <c>--password-env</c>, whose default is the canonical
|
||||
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
|
||||
/// </summary>
|
||||
/// <param name="passwordEnvArgument">The value passed to <c>--password-env</c>, or null to use the default.</param>
|
||||
/// <param name="environmentName">The environment variable that holds the credential.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
|
||||
@@ -340,7 +345,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: the pre-rename names stay usable for one release — the deprecated
|
||||
/// the pre-rename names stay usable for one release — the deprecated
|
||||
/// <c>--verify-user-password</c> flag and the deprecated
|
||||
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
|
||||
/// </summary>
|
||||
@@ -413,7 +418,7 @@ public sealed class MxGatewayClientCliTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
|
||||
/// a missing or empty credential fails fast before the invoke — the CLI
|
||||
/// never sends a fabricated empty password to the wire. The error names the flag
|
||||
/// and the environment variable, never a value.
|
||||
/// </summary>
|
||||
@@ -463,6 +468,9 @@ public sealed class MxGatewayClientCliTests
|
||||
private readonly string _name;
|
||||
private readonly string? _original;
|
||||
|
||||
/// <summary>Sets the environment variable and remembers its original value.</summary>
|
||||
/// <param name="name">The environment variable name.</param>
|
||||
/// <param name="value">The temporary value, or null to clear it.</param>
|
||||
public EnvironmentVariableScope(string name, string? value)
|
||||
{
|
||||
_name = name;
|
||||
@@ -470,6 +478,7 @@ public sealed class MxGatewayClientCliTests
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
/// <summary>Restores the original environment variable value.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(_name, _original);
|
||||
@@ -588,7 +597,7 @@ public sealed class MxGatewayClientCliTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
|
||||
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02).
|
||||
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
@@ -705,11 +714,56 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that false alarm-feed status flags remain explicit in text while protobuf JSON omits defaults.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamAlarms_StatusFramesDistinguishTextFromProtobufJson()
|
||||
{
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
ProviderStatus = new AlarmProviderStatus { Degraded = false },
|
||||
});
|
||||
fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
|
||||
{
|
||||
SnapshotStatus = new AlarmSnapshotStatus { Truncated = false },
|
||||
});
|
||||
|
||||
using var textOutput = new StringWriter();
|
||||
using var textError = new StringWriter();
|
||||
int textExit = await MxGatewayClientCli.RunAsync(
|
||||
["stream-alarms", "--endpoint", "http://localhost:5000", "--api-key", "test-api-key", "--max-events", "2"],
|
||||
textOutput,
|
||||
textError,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, textExit);
|
||||
Assert.Equal(
|
||||
["provider-status mode=Unspecified degraded=false reason=\"\"", "snapshot-status truncated=false"],
|
||||
textOutput.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries));
|
||||
Assert.Equal(string.Empty, textError.ToString());
|
||||
|
||||
using var jsonOutput = new StringWriter();
|
||||
using var jsonError = new StringWriter();
|
||||
int jsonExit = await MxGatewayClientCli.RunAsync(
|
||||
["stream-alarms", "--endpoint", "http://localhost:5000", "--api-key", "test-api-key", "--max-events", "2", "--jsonl"],
|
||||
jsonOutput,
|
||||
jsonError,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, jsonExit);
|
||||
Assert.Equal(
|
||||
["{ \"providerStatus\": { } }", "{ \"snapshotStatus\": { } }"],
|
||||
jsonOutput.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries));
|
||||
Assert.Equal(string.Empty, jsonError.ToString());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[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();
|
||||
@@ -733,6 +787,10 @@ public sealed class MxGatewayClientCliTests
|
||||
"ack from cli",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-domain",
|
||||
"CORP",
|
||||
"--operator-password-env",
|
||||
"MXGW_TEST_OPPW",
|
||||
"--json",
|
||||
],
|
||||
output,
|
||||
@@ -744,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());
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
|
||||
/// events pass through unchanged with IsReplayGap false.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
|
||||
{
|
||||
@@ -430,6 +431,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_BuildsUnregisterCommand()
|
||||
{
|
||||
@@ -451,6 +453,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
|
||||
{
|
||||
@@ -473,6 +476,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
|
||||
{
|
||||
@@ -498,6 +502,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
|
||||
{
|
||||
@@ -520,6 +525,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
@@ -545,6 +551,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
|
||||
{
|
||||
@@ -568,6 +575,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
|
||||
{
|
||||
@@ -598,6 +606,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// natively. The client must surface that scripted failure (as an
|
||||
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
|
||||
{
|
||||
@@ -621,6 +630,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Async_BuildsWriteSecured2Command()
|
||||
{
|
||||
@@ -647,6 +657,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
@@ -675,6 +686,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
/// must never contain the credential — the error path is built only from
|
||||
/// reply-derived diagnostics, not the request payload.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
|
||||
{
|
||||
@@ -698,6 +710,7 @@ public sealed class MxGatewayClientSessionTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySecretRedactionTests
|
||||
{
|
||||
/// <summary>Test: Redact replaces every occurrence of secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_ReplacesEveryOccurrenceOfSecret()
|
||||
{
|
||||
@@ -19,6 +20,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact scrubs both secrets when one is substring of the other.</summary>
|
||||
[Fact]
|
||||
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
|
||||
{
|
||||
@@ -33,6 +35,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with null secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -43,6 +46,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact with empty secrets array returns message unchanged.</summary>
|
||||
[Fact]
|
||||
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
|
||||
{
|
||||
@@ -53,6 +57,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redact ignores whitespace only secret.</summary>
|
||||
[Fact]
|
||||
public void Redact_IgnoresWhitespaceOnlySecret()
|
||||
{
|
||||
@@ -64,6 +69,7 @@ public sealed class MxGatewaySecretRedactionTests
|
||||
Assert.Equal(message, result);
|
||||
}
|
||||
|
||||
/// <summary>Test: Redacted preserves concrete subtype and does not chain secret bearing original.</summary>
|
||||
[Fact]
|
||||
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
|
||||
{
|
||||
|
||||
+11
-6
@@ -4,15 +4,16 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the credential-scrub (CLI-40) and malformed-reply (CLI-41) contracts on the
|
||||
/// Tests for the credential-scrub and malformed-reply contracts on the
|
||||
/// credential and id-returning session helpers, driven from shared behavior fixtures.
|
||||
/// </summary>
|
||||
public sealed class MxGatewaySessionReplyContractTests
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI-40: when MXAccess echoes the submitted credential back in its failure diagnostic,
|
||||
/// Verifies that when MXAccess echoes the submitted credential back in its failure diagnostic,
|
||||
/// the surfaced exception message must scrub it to the library redaction marker.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage()
|
||||
{
|
||||
@@ -33,13 +34,14 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-40: the redacted exception must not leak the echoed credential through any structured
|
||||
/// Verifies that the redacted exception must not leak the echoed credential through any structured
|
||||
/// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic
|
||||
/// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/>
|
||||
/// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT
|
||||
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
|
||||
/// </summary>
|
||||
/// <param name="fixture">The echoed-credential reply fixture to drive.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[InlineData("authenticate-user.echoed-credential.reply.json")]
|
||||
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
|
||||
@@ -71,9 +73,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// Verifies that an OK reply that carries neither the typed AuthenticateUser payload nor an
|
||||
/// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
@@ -87,9 +90,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// Verifies that an OK reply that omits the typed payload but carries an int32 return_value
|
||||
/// resolves to that return value.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue()
|
||||
{
|
||||
@@ -104,9 +108,10 @@ public sealed class MxGatewaySessionReplyContractTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-41: the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
|
||||
/// Verifies that the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
|
||||
/// with neither a typed item handle nor an int32 return_value throws the typed exception.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
|
||||
{
|
||||
|
||||
@@ -32,6 +32,9 @@ public sealed class MxStatusProxyExtensionsTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the raw success member never overrides the authoritative category.</summary>
|
||||
/// <param name="category">The status category under test.</param>
|
||||
/// <param name="success">The raw success member, which must not influence the result.</param>
|
||||
/// <param name="expected">The expected <c>IsSuccess</c> result.</param>
|
||||
[Theory]
|
||||
[InlineData(MxStatusCategory.Ok, 0, true)]
|
||||
[InlineData(MxStatusCategory.Ok, 1, true)]
|
||||
|
||||
@@ -96,7 +96,8 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Stops and disposes the underlying web application.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -854,6 +854,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task UnregisterAsync(
|
||||
int serverHandle,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -888,6 +889,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task AdviseSupervisoryAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -985,6 +987,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task SetBufferedUpdateIntervalAsync(
|
||||
int serverHandle,
|
||||
int updateIntervalMilliseconds,
|
||||
@@ -1125,6 +1128,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecuredAsync(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
@@ -1204,6 +1208,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
/// <param name="currentUserId">The current operator user id.</param>
|
||||
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WriteSecured2Async(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.2.1</Version>
|
||||
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
|
||||
|
||||
+12
-3
@@ -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.
|
||||
@@ -162,6 +166,11 @@ on every verdict change — including the clearing frame sent when the gateway's
|
||||
alarm monitor restarts and drops a truncated verdict. Track it if you need set
|
||||
completeness on a live feed without polling `QueryActiveAlarms`.
|
||||
|
||||
The CLI's text rows always spell out `degraded=false` and `truncated=false`.
|
||||
Its protobuf-JSON mode retains the standard proto3 omission rule, so those
|
||||
false-valued payloads appear as `{"providerStatus":{}}` and
|
||||
`{"snapshotStatus":{}}`; absent means false.
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
@@ -419,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. |
|
||||
@@ -439,7 +448,7 @@ go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -typ
|
||||
go run ./cmd/mxgw-go write-bulk -session-id <id> -server-handle 1 -item-handles 1,2 -values 10,20 -type int32 -plaintext -json
|
||||
go run ./cmd/mxgw-go read-bulk -session-id <id> -item-handles 1,2 -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-alarms -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-alarms -plaintext -limit 4 -json
|
||||
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -283,6 +283,68 @@ type pingFakeGateway struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
}
|
||||
|
||||
// alarmStatusFakeGateway emits both status arms with their proto3 boolean
|
||||
// defaults so the CLI contract covers values that protobuf JSON omits.
|
||||
type alarmStatusFakeGateway struct {
|
||||
pb.UnimplementedMxAccessGatewayServer
|
||||
}
|
||||
|
||||
func (g *alarmStatusFakeGateway) StreamAlarms(
|
||||
_ *pb.StreamAlarmsRequest,
|
||||
stream grpc.ServerStreamingServer[pb.AlarmFeedMessage],
|
||||
) error {
|
||||
if err := stream.Send(&pb.AlarmFeedMessage{
|
||||
Payload: &pb.AlarmFeedMessage_ProviderStatus{
|
||||
ProviderStatus: &pb.AlarmProviderStatus{Degraded: false},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return stream.Send(&pb.AlarmFeedMessage{
|
||||
Payload: &pb.AlarmFeedMessage_SnapshotStatus{
|
||||
SnapshotStatus: &pb.AlarmSnapshotStatus{Truncated: false},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func startAlarmStatusGateway(t *testing.T) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
server := grpc.NewServer()
|
||||
pb.RegisterMxAccessGatewayServer(server, &alarmStatusFakeGateway{})
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(func() {
|
||||
server.Stop()
|
||||
_ = listener.Close()
|
||||
})
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func TestRunStreamAlarmsPrintsStatusArmsInTextAndProtobufJSON(t *testing.T) {
|
||||
endpoint := startAlarmStatusGateway(t)
|
||||
baseArgs := []string{"stream-alarms", "-endpoint", endpoint, "-plaintext", "-api-key", "test", "-limit", "2"}
|
||||
|
||||
var textOut, textErr bytes.Buffer
|
||||
if err := runWithIO(t.Context(), baseArgs, &textOut, &textErr); err != nil {
|
||||
t.Fatalf("text stream-alarms error = %v; stderr = %s", err, textErr.String())
|
||||
}
|
||||
if got, want := strings.TrimSpace(textOut.String()), "provider-status mode=ALARM_PROVIDER_MODE_UNSPECIFIED degraded=false reason=\"\"\nsnapshot-status truncated=false"; got != want {
|
||||
t.Fatalf("text stream-alarms output = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
var jsonOut, jsonErr bytes.Buffer
|
||||
jsonArgs := append(append([]string{}, baseArgs...), "-json")
|
||||
if err := runWithIO(t.Context(), jsonArgs, &jsonOut, &jsonErr); err != nil {
|
||||
t.Fatalf("JSON stream-alarms error = %v; stderr = %s", err, jsonErr.String())
|
||||
}
|
||||
if got, want := strings.TrimSpace(jsonOut.String()), "{\"providerStatus\":{}}\n{\"snapshotStatus\":{}}"; got != want {
|
||||
t.Fatalf("JSON stream-alarms output = %q, want protobuf JSON %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *pingFakeGateway) Invoke(_ context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
||||
echo := "pong:" + req.GetCommand().GetPing().GetMessage()
|
||||
return &pb.MxCommandReply{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -127,9 +127,14 @@ type (
|
||||
QueryActiveAlarmsRequest = pb.QueryActiveAlarmsRequest
|
||||
// StreamAlarmsRequest is the gateway StreamAlarms request message.
|
||||
StreamAlarmsRequest = pb.StreamAlarmsRequest
|
||||
// AlarmFeedMessage is one message on the StreamAlarms feed — an
|
||||
// active-alarm snapshot row, a snapshot-complete sentinel, or a transition.
|
||||
// AlarmFeedMessage is one message on the StreamAlarms feed — an active-alarm
|
||||
// snapshot row, snapshot-complete sentinel, transition, provider status, or
|
||||
// snapshot-completeness status.
|
||||
AlarmFeedMessage = pb.AlarmFeedMessage
|
||||
// AlarmProviderStatus reports the alarm provider mode and degradation state.
|
||||
AlarmProviderStatus = pb.AlarmProviderStatus
|
||||
// AlarmSnapshotStatus reports whether the cached alarm snapshot is truncated.
|
||||
AlarmSnapshotStatus = pb.AlarmSnapshotStatus
|
||||
// ActiveAlarmSnapshot is one row in a ConditionRefresh stream.
|
||||
ActiveAlarmSnapshot = pb.ActiveAlarmSnapshot
|
||||
// OnAlarmTransitionEvent is the body carried by alarm-transition MxEvents.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package mxgateway_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
)
|
||||
|
||||
// TestAlarmFeedStatusAliasesArePublic is a compile-time consumer contract for
|
||||
// the two status payloads carried by AlarmFeedMessage.
|
||||
func TestAlarmFeedStatusAliasesArePublic(t *testing.T) {
|
||||
t.Helper()
|
||||
var provider mxgateway.AlarmProviderStatus
|
||||
var snapshot mxgateway.AlarmSnapshotStatus
|
||||
_ = provider
|
||||
_ = snapshot
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package mxgateway
|
||||
const (
|
||||
// ClientVersion is the released semantic version of this Go client module.
|
||||
// Keep it in sync with the module tag applied by scripts/tag-go-module.ps1.
|
||||
ClientVersion = "0.2.0"
|
||||
ClientVersion = "0.2.1"
|
||||
|
||||
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||
// in the shared .NET contracts.
|
||||
|
||||
+13
-5
@@ -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
|
||||
@@ -135,6 +138,11 @@ the clearing frame sent when the gateway's alarm monitor restarts and drops a
|
||||
truncated verdict. Track it if you need set completeness on a live feed without
|
||||
polling `queryActiveAlarms`.
|
||||
|
||||
The CLI's text rows always spell out `degraded=false` and `truncated=false`.
|
||||
Its protobuf-JSON mode retains the standard proto3 omission rule, so those
|
||||
false-valued payloads appear as `{"providerStatus":{}}` and
|
||||
`{"snapshotStatus":{}}`; absent means false.
|
||||
|
||||
## Write Semantics And Common Pitfalls
|
||||
|
||||
These are MXAccess parity behaviors that surprise new callers. The gateway
|
||||
@@ -411,8 +419,8 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="advise-supervisory --endpoint localh
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --verify-user operator --password-env MXGATEWAY_VERIFY_PASSWORD --json"
|
||||
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 1 --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="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\" --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"
|
||||
```
|
||||
|
||||
@@ -483,7 +491,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.2.1'
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.2.2'
|
||||
}
|
||||
````
|
||||
|
||||
|
||||
@@ -16,11 +16,14 @@ subprojects {
|
||||
// 0.2.0 was already published to the Gitea Maven feed on 2026-06-26,
|
||||
// before the CLI-37/38/40/41 conformance fixes changed the client's
|
||||
// observable behavior (status.category-based validation, hresult < 0,
|
||||
// exact-secret redaction, typed malformed-reply errors). Bump to 0.2.1
|
||||
// so the published coordinate matches the conformant behavior the other
|
||||
// four clients ship at 0.2.0 for the first time. See CLI-39 and the
|
||||
// exact-secret redaction, typed malformed-reply errors). Bumped to 0.2.1
|
||||
// so the published coordinate matched the conformant behavior the other
|
||||
// four clients shipped at 0.2.0 for the first time. See CLI-39 and the
|
||||
// "Versioning" section of docs/ClientPackaging.md.
|
||||
version = '0.2.1'
|
||||
// 0.2.1 was itself published (Maven, 2026-08-07); this is the next
|
||||
// release-train bump (CLI-46), one ahead of the other four clients'
|
||||
// 0.2.1 because the Java coordinate has always trailed by one number.
|
||||
version = '0.2.2'
|
||||
|
||||
pluginManager.withPlugin('java') {
|
||||
java {
|
||||
|
||||
+2656
-654
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",
|
||||
|
||||
+36
-2
@@ -27,6 +27,8 @@ import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
@@ -60,7 +62,7 @@ final class MxGatewayCliTests {
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("", run.errors());
|
||||
assertTrue(run.output().contains("mxgateway-java 0.2.1"));
|
||||
assertTrue(run.output().contains("mxgateway-java 0.2.2"));
|
||||
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
||||
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
||||
}
|
||||
@@ -90,7 +92,7 @@ final class MxGatewayCliTests {
|
||||
CliRun run = execute(new FakeClientFactory(), "version", "--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.2.1\""));
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.2.2\""));
|
||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
||||
}
|
||||
|
||||
@@ -685,6 +687,21 @@ final class MxGatewayCliTests {
|
||||
assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamAlarmsCommandPrintsStatusArmsInTextAndProtobufJson() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
|
||||
CliRun text = execute(factory, "stream-alarms");
|
||||
List<String> textRows = text.output().lines().filter(line -> !line.isBlank()).toList();
|
||||
assertEquals("provider-status mode=ALARM_PROVIDER_MODE_UNSPECIFIED degraded=false reason=", textRows.get(3));
|
||||
assertEquals("snapshot-status truncated=false", textRows.get(4));
|
||||
|
||||
CliRun json = execute(factory, "stream-alarms", "--json");
|
||||
List<String> jsonRows = json.output().lines().filter(line -> !line.isBlank()).toList();
|
||||
assertEquals("{\"providerStatus\":{}}", jsonRows.get(3));
|
||||
assertEquals("{\"snapshotStatus\":{}}", jsonRows.get(4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
|
||||
FakeClientFactory factory = new FakeClientFactory();
|
||||
@@ -697,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());
|
||||
@@ -753,6 +777,10 @@ final class MxGatewayCliTests {
|
||||
"--plaintext",
|
||||
"--reference",
|
||||
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||
"--operator",
|
||||
"operator1",
|
||||
"--operator-password-env",
|
||||
"MXGATEWAY_OPERATOR_PASSWORD",
|
||||
"--json"
|
||||
};
|
||||
assertReadmeExampleParses(args);
|
||||
@@ -1409,6 +1437,12 @@ final class MxGatewayCliTests {
|
||||
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||
.setSeverity(700))
|
||||
.build());
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setProviderStatus(AlarmProviderStatus.newBuilder().setDegraded(false))
|
||||
.build());
|
||||
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||
.setSnapshotStatus(AlarmSnapshotStatus.newBuilder().setTruncated(false))
|
||||
.build());
|
||||
observer.onCompleted();
|
||||
return new MxGatewayAlarmFeedSubscription();
|
||||
}
|
||||
|
||||
+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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ package com.zb.mom.ww.mxgateway.client;
|
||||
public final class MxGatewayClientVersion {
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 3;
|
||||
private static final int WORKER_PROTOCOL_VERSION = 1;
|
||||
private static final String CLIENT_VERSION = "0.2.1";
|
||||
private static final String CLIENT_VERSION = "0.2.2";
|
||||
|
||||
private MxGatewayClientVersion() {
|
||||
}
|
||||
|
||||
+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
|
||||
@@ -132,6 +138,14 @@ sent when the gateway's alarm monitor restarts and drops a truncated verdict.
|
||||
Track it if you need set completeness on a live feed without polling
|
||||
`query_active_alarms`.
|
||||
|
||||
`mxgw-py stream-alarms` writes one JSON row as each feed message arrives. The
|
||||
default `--max-messages 0` is unbounded; use a positive value for a finite
|
||||
sample, remembering that the provider-status and snapshot-status preamble both
|
||||
count toward it. `--timeout` ends the command after an idle wait and the stream
|
||||
is closed in every exit path. Protobuf JSON omits false-valued scalar defaults,
|
||||
so healthy/clearing frames appear as `{"providerStatus": {}}` and
|
||||
`{"snapshotStatus": {}}`; absent means false.
|
||||
|
||||
Canceling a Python task cancels the client-side gRPC call or stream wait. It
|
||||
does not abort an in-flight MXAccess COM call inside the worker process.
|
||||
|
||||
@@ -448,8 +462,8 @@ mxgw-py register --session-id <id> --client-name python-client --json
|
||||
mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --json
|
||||
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 1 --json
|
||||
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
mxgw-py stream-alarms --max-messages 4 --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
|
||||
```
|
||||
|
||||
@@ -484,6 +498,15 @@ mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGAT
|
||||
|
||||
## Installing from the Gitea PyPI Feed
|
||||
|
||||
**The published `0.2.0` artifact cannot connect.** Its `connect()` built the
|
||||
`grpc.aio` channel inside `asyncio.to_thread`, and a `grpc.aio` channel binds
|
||||
to the event loop current on the *constructing* thread — so every
|
||||
non-stub connection raised `RuntimeError: There is no current event loop in
|
||||
thread`. Fixed in-tree by commit `a8f86b5` (`resolve_channel_security` now
|
||||
runs off-loop, `create_channel` runs on-loop). The fix is superseded by
|
||||
`0.2.1`; do not install `0.2.0` from the feed below — use `0.2.1` or later,
|
||||
or install from source at a commit at or after `a8f86b5`.
|
||||
|
||||
The client publishes to the internal Gitea PyPI feed:
|
||||
|
||||
````bash
|
||||
|
||||
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "zb-mom-ww-mxaccess-gateway-client"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "Async Python client for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Package version information."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -511,16 +512,15 @@ def stream_events(**kwargs: Any) -> None:
|
||||
@main.command("stream-alarms")
|
||||
@gateway_options
|
||||
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
|
||||
@click.option("--max-messages", default=1, type=int, show_default=True)
|
||||
@click.option("--max-messages", default=0, type=int, show_default=True, help="Maximum rows to emit; 0 means unbounded.")
|
||||
@click.option("--timeout", default=5.0, type=float, show_default=True)
|
||||
@click.option("--correlation-id", default="", help="Client correlation id.")
|
||||
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
|
||||
def stream_alarms(**kwargs: Any) -> None:
|
||||
"""Stream a bounded number of messages from the gateway's central alarm feed."""
|
||||
"""Stream messages from the gateway's central alarm feed as they arrive."""
|
||||
|
||||
_run(
|
||||
_run_streaming(
|
||||
_stream_alarms(**kwargs),
|
||||
output_json=kwargs["output_json"],
|
||||
secrets=_secrets(kwargs),
|
||||
)
|
||||
|
||||
@@ -529,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],
|
||||
)
|
||||
|
||||
|
||||
@@ -837,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.
|
||||
|
||||
@@ -1119,19 +1148,39 @@ async def _stream_events(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"events": [_event_row(event) for event in events]}
|
||||
|
||||
|
||||
async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
messages = await _collect_alarm_messages(
|
||||
client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||
),
|
||||
),
|
||||
max_messages=kwargs["max_messages"],
|
||||
timeout=kwargs["timeout"],
|
||||
async def _stream_alarms(**kwargs: Any) -> None:
|
||||
max_messages = int(kwargs["max_messages"])
|
||||
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-messages",
|
||||
)
|
||||
return {"messages": [_message_dict(message) for message in messages]}
|
||||
|
||||
async with await _connect(kwargs) as client:
|
||||
messages = client.stream_alarms(
|
||||
pb.StreamAlarmsRequest(
|
||||
client_correlation_id=kwargs["correlation_id"],
|
||||
alarm_filter_prefix=kwargs["filter_prefix"],
|
||||
),
|
||||
)
|
||||
iterator = messages.__aiter__()
|
||||
emitted = 0
|
||||
try:
|
||||
while max_messages == 0 or emitted < max_messages:
|
||||
try:
|
||||
message = await asyncio.wait_for(
|
||||
iterator.__anext__(),
|
||||
timeout=kwargs["timeout"],
|
||||
)
|
||||
except (StopAsyncIteration, asyncio.TimeoutError):
|
||||
break
|
||||
_emit(_message_dict(message), output_json=kwargs["output_json"])
|
||||
sys.stdout.flush()
|
||||
emitted += 1
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
|
||||
|
||||
async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
|
||||
@@ -1142,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)
|
||||
@@ -1489,6 +1540,17 @@ def _run(
|
||||
_emit(payload, output_json=output_json)
|
||||
|
||||
|
||||
def _run_streaming(
|
||||
awaitable: Awaitable[None],
|
||||
*,
|
||||
secrets: list[str | None],
|
||||
) -> None:
|
||||
try:
|
||||
asyncio.run(awaitable)
|
||||
except MxGatewayError as error:
|
||||
raise click.ClickException(redact_secret(str(error), secrets)) from error
|
||||
|
||||
|
||||
def _emit(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
@@ -1536,34 +1598,6 @@ async def _collect_events(
|
||||
return collected
|
||||
|
||||
|
||||
async def _collect_alarm_messages(
|
||||
messages: Any,
|
||||
*,
|
||||
max_messages: int,
|
||||
timeout: float,
|
||||
) -> list[pb.AlarmFeedMessage]:
|
||||
if max_messages > MAX_AGGREGATE_EVENTS:
|
||||
raise click.BadParameter(
|
||||
f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
|
||||
param_hint="--max-messages",
|
||||
)
|
||||
|
||||
collected: list[pb.AlarmFeedMessage] = []
|
||||
iterator = messages.__aiter__()
|
||||
try:
|
||||
while len(collected) < max_messages:
|
||||
collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
close = getattr(iterator, "aclose", None)
|
||||
if close is not None:
|
||||
await close()
|
||||
return collected
|
||||
|
||||
|
||||
async def _collect_deploy_events(
|
||||
events: Any,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -7,6 +7,7 @@ fixed source.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time as _time_module_ref
|
||||
@@ -296,6 +297,22 @@ class _AlarmFakeStream:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
class _IncrementalAlarmFakeStream(_AlarmFakeStream):
|
||||
def __init__(self, messages: list[pb.AlarmFeedMessage], emitted: list[str]) -> None:
|
||||
super().__init__(messages)
|
||||
self._emitted = emitted
|
||||
self._yielded = 0
|
||||
|
||||
async def __anext__(self) -> pb.AlarmFeedMessage:
|
||||
if self._yielded > 0:
|
||||
assert len(self._emitted) >= self._yielded, "previous alarm row was buffered"
|
||||
if not self._messages:
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
self._yielded += 1
|
||||
return self._messages.pop(0)
|
||||
|
||||
|
||||
class _BulkFakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
@@ -667,8 +684,15 @@ def test_cli_write_bulk_happy_path(monkeypatch) -> None:
|
||||
assert cmd.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
||||
|
||||
|
||||
def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
transitions = [
|
||||
def test_cli_stream_alarms_default_streams_status_cached_and_live_rows_incrementally(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
messages = [
|
||||
pb.AlarmFeedMessage(provider_status=pb.AlarmProviderStatus(degraded=False)),
|
||||
pb.AlarmFeedMessage(snapshot_status=pb.AlarmSnapshotStatus(truncated=False)),
|
||||
pb.AlarmFeedMessage(
|
||||
active_alarm=pb.ActiveAlarmSnapshot(alarm_full_reference="Tank01.Level.HiHi"),
|
||||
),
|
||||
pb.AlarmFeedMessage(
|
||||
transition=pb.OnAlarmTransitionEvent(
|
||||
alarm_full_reference="Tank01.Level.HiHi",
|
||||
@@ -676,7 +700,15 @@ def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
),
|
||||
),
|
||||
]
|
||||
stream = _AlarmFakeStream(transitions)
|
||||
emitted: list[str] = []
|
||||
real_echo = cli_commands.click.echo
|
||||
|
||||
def recording_echo(message=None, **kwargs) -> None:
|
||||
emitted.append(str(message))
|
||||
real_echo(message, **kwargs)
|
||||
|
||||
monkeypatch.setattr(cli_commands.click, "echo", recording_echo)
|
||||
stream = _IncrementalAlarmFakeStream(messages, emitted)
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_alarm_stream(stream)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
@@ -689,10 +721,8 @@ def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--max-messages",
|
||||
"1",
|
||||
"--timeout",
|
||||
"5.0",
|
||||
"0.01",
|
||||
"--filter-prefix",
|
||||
"Tank01.",
|
||||
"--json",
|
||||
@@ -700,11 +730,48 @@ def test_cli_stream_alarms_happy_path(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["messages"][0]["transition"]["alarmFullReference"] == "Tank01.Level.HiHi"
|
||||
rows = [json.loads(line) for line in result.output.splitlines()]
|
||||
assert [next(iter(row)) for row in rows] == [
|
||||
"providerStatus",
|
||||
"snapshotStatus",
|
||||
"activeAlarm",
|
||||
"transition",
|
||||
]
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) -> None:
|
||||
messages = [
|
||||
pb.AlarmFeedMessage(provider_status=pb.AlarmProviderStatus()),
|
||||
pb.AlarmFeedMessage(snapshot_status=pb.AlarmSnapshotStatus()),
|
||||
pb.AlarmFeedMessage(active_alarm=pb.ActiveAlarmSnapshot()),
|
||||
]
|
||||
stream = _AlarmFakeStream(messages)
|
||||
stub = _BulkFakeStub()
|
||||
stub.set_alarm_stream(stream)
|
||||
_install_fake_connect(monkeypatch, stub)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"stream-alarms",
|
||||
"--endpoint",
|
||||
"localhost:5000",
|
||||
"--plaintext",
|
||||
"--max-messages",
|
||||
"2",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
rows = [json.loads(line) for line in result.output.splitlines()]
|
||||
assert [next(iter(row)) for row in rows] == ["providerStatus", "snapshotStatus"]
|
||||
assert stream.cancelled
|
||||
|
||||
|
||||
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
|
||||
stub = _BulkFakeStub()
|
||||
stub.acknowledge_alarm = _BulkFakeUnary(
|
||||
[
|
||||
@@ -732,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"
|
||||
|
||||
Generated
+2
-2
@@ -590,7 +590,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "mxgw-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures-util",
|
||||
@@ -1490,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
edition = "2021"
|
||||
authors = ["Joseph Doherty"]
|
||||
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
|
||||
@@ -25,7 +25,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
|
||||
+11
-2
@@ -77,8 +77,9 @@ cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-r
|
||||
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
|
||||
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 1 --json
|
||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --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
|
||||
@@ -141,6 +145,11 @@ every verdict change — including the clearing frame sent when the gateway's
|
||||
alarm monitor restarts and drops a truncated verdict. Track it if you need set
|
||||
completeness on a live feed without polling `query_active_alarms`.
|
||||
|
||||
Unlike the protobuf-JSON formatters used by the other CLIs, the Rust CLI's
|
||||
hand-built JSON status rows always include `"degraded": false` and
|
||||
`"truncated": false`. This deliberate representational difference is
|
||||
documented in the cross-language smoke matrix; both shapes mean the same thing.
|
||||
|
||||
The session helpers keep MXAccess handles visible:
|
||||
|
||||
```rust
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -130,6 +130,7 @@ message MxCommand {
|
||||
WriteSecuredBulkCommand write_secured_bulk = 41;
|
||||
WriteSecured2BulkCommand write_secured2_bulk = 42;
|
||||
ReadBulkCommand read_bulk = 43;
|
||||
UpdateAlarmWatchListCommand update_alarm_watch_list = 44;
|
||||
PingCommand ping = 100;
|
||||
GetSessionStateCommand get_session_state = 101;
|
||||
GetWorkerInfoCommand get_worker_info = 102;
|
||||
@@ -174,6 +175,7 @@ enum MxCommandKind {
|
||||
MX_COMMAND_KIND_WRITE_SECURED_BULK = 32;
|
||||
MX_COMMAND_KIND_WRITE_SECURED2_BULK = 33;
|
||||
MX_COMMAND_KIND_READ_BULK = 34;
|
||||
MX_COMMAND_KIND_UPDATE_ALARM_WATCH_LIST = 35;
|
||||
MX_COMMAND_KIND_PING = 100;
|
||||
MX_COMMAND_KIND_GET_SESSION_STATE = 101;
|
||||
MX_COMMAND_KIND_GET_WORKER_INFO = 102;
|
||||
@@ -344,8 +346,8 @@ enum AlarmProviderMode {
|
||||
// Subscription expression follows the canonical
|
||||
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
||||
// worker spins up a wnwrapConsumer-backed subscription on its STA on
|
||||
// first call; subsequent calls are an error (use UnsubscribeAlarms then
|
||||
// SubscribeAlarms to reconfigure).
|
||||
// first call; subsequent calls are an error. Use UpdateAlarmWatchList to
|
||||
// replace the standby targets without restarting the primary subscription.
|
||||
message SubscribeAlarmsCommand {
|
||||
string subscription_expression = 1;
|
||||
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||
@@ -354,6 +356,17 @@ message SubscribeAlarmsCommand {
|
||||
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||
repeated AlarmSubtagTarget watch_list = 3;
|
||||
AlarmFailoverConfig failover = 4;
|
||||
// True when auto mode should retain subtag-fallback capability even if the
|
||||
// gateway's initial discovery produced an empty watch list. False leaves an
|
||||
// empty list as an intentional alarm-manager-only subscription.
|
||||
bool fallback_enabled = 5;
|
||||
}
|
||||
|
||||
// Replace the live alarm subscription's subtag standby targets. The worker
|
||||
// constructs and attaches the replacement before swapping it into service, so
|
||||
// a setup failure leaves the existing standby and active provider unchanged.
|
||||
message UpdateAlarmWatchListCommand {
|
||||
repeated AlarmSubtagTarget watch_list = 1;
|
||||
}
|
||||
|
||||
// Tear down the worker's alarm consumer. No-op if no subscription is
|
||||
@@ -378,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;
|
||||
@@ -390,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
|
||||
@@ -401,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
|
||||
@@ -418,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 {
|
||||
@@ -537,11 +559,14 @@ message MxCommandReply {
|
||||
MxValue return_value = 6;
|
||||
// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
|
||||
// WRITE_SECURED2 replies the worker holds the reply for a bounded window
|
||||
// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
|
||||
// (default 1.5 s, MxGateway:Worker:WriteCompletionWaitMilliseconds) waiting for
|
||||
// the matching MXAccess OnWriteComplete callback and copies its status rows
|
||||
// here, so statuses[0] carries the real MXAccess commit outcome (success OR
|
||||
// failure) while protocol_status/hresult still describe command acceptance
|
||||
// only. Empty statuses on a write reply means the completion did not arrive
|
||||
// only. Callback correlation is recency-based per item handle: after the
|
||||
// bounded wait, a sequential write's callback supersedes a timed-out
|
||||
// predecessor, and a later stale callback for that predecessor is ignored.
|
||||
// Empty statuses on a write reply means the completion did not arrive
|
||||
// within the window — the write is unconfirmed, not failed. Correlation is
|
||||
// best-effort per (server_handle, item_handle): MXAccess's callback carries
|
||||
// no transaction id, so concurrent writes to the same item within the
|
||||
@@ -709,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 {
|
||||
@@ -971,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 {
|
||||
@@ -981,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};
|
||||
|
||||
@@ -13,7 +13,10 @@ pub mod mxaccess_gateway {
|
||||
/// `mxaccess_gateway.v1` package — the v1 wire contract surfaced by the
|
||||
/// gateway to language clients.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
// tonic's generated server traits use async_trait, whose expansion adds
|
||||
// redundant must_use attributes to Future returns under Rust 1.99.
|
||||
#![allow(clippy::double_must_use)]
|
||||
|
||||
tonic::include_proto!("mxaccess_gateway.v1");
|
||||
}
|
||||
@@ -24,7 +27,7 @@ pub mod mxaccess_worker {
|
||||
/// `mxaccess_worker.v1` package — frame and envelope types used inside
|
||||
/// the named-pipe transport between gateway and worker.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
|
||||
tonic::include_proto!("mxaccess_worker.v1");
|
||||
}
|
||||
@@ -35,7 +38,10 @@ pub mod galaxy_repository {
|
||||
/// `galaxy_repository.v1` package — types for the Galaxy hierarchy
|
||||
/// discovery and deploy-event watch RPCs.
|
||||
pub mod v1 {
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
|
||||
// tonic's generated server traits use async_trait, whose expansion adds
|
||||
// redundant must_use attributes to Future returns under Rust 1.99.
|
||||
#![allow(clippy::double_must_use)]
|
||||
|
||||
tonic::include_proto!("galaxy_repository.v1");
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
+139
-38
@@ -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,23 +786,58 @@ 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)
|
||||
|
||||
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 experiment enabled:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| 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` |
|
||||
|
||||
Three separate problems follow:
|
||||
|
||||
1. **The two libraries cannot share a process.** The worker always runs
|
||||
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 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
|
||||
client holding records. Getting the client's own identifier for the
|
||||
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
|
||||
FILETIME `ArgumentOutOfRangeException` described above. The
|
||||
`MxSecurityOperate` question was therefore never reached.
|
||||
3. **The gateway does not send the identity fields.** Its GUID arm fills
|
||||
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
|
||||
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.
|
||||
|
||||
The route was dropped in favour of the authenticated `AckMsg` write
|
||||
(`AlarmProbeFindings.md`, "Fourth attempt").
|
||||
|
||||
### 5. STA / threading — resolved
|
||||
|
||||
@@ -835,7 +881,7 @@ carries `degraded = true`.
|
||||
### Watch-list discovery
|
||||
|
||||
`GatewayAlarmMonitor` resolves the subtag watch-list at subscribe time by
|
||||
calling `IAlarmWatchListResolver.GetAlarmAttributesAsync`. The resolver merges:
|
||||
calling `IAlarmWatchListResolver.ResolveAsync`. The resolver merges:
|
||||
|
||||
1. Galaxy Repository SQL (`GetAlarmAttributesAsync`) — objects that have alarm
|
||||
extensions in the configured area.
|
||||
@@ -847,8 +893,47 @@ The resolved list is a set of `AlarmSubtagTarget` messages sent to the worker
|
||||
inside `SubscribeAlarmsCommand.watch_list`. Each target carries the composed
|
||||
MXAccess item addresses for the `InAlarm`, `Acked`, `AckMsg`, and `Priority`
|
||||
subtags (confirmed AVEVA `AlarmExtension` field names, verified against the live
|
||||
ZB Galaxy `attribute_definition` rows). The gateway re-runs discovery on its
|
||||
reconcile cadence and pushes an updated watch-list when the model changes.
|
||||
ZB Galaxy `attribute_definition` rows).
|
||||
|
||||
The gateway refreshes this list on the existing alarm reconciliation cadence
|
||||
and immediately before publishing a failover-to-subtag status. It compares a
|
||||
canonical fingerprint of all six target fields and sends
|
||||
`UpdateAlarmWatchListCommand` only for a material change. The worker constructs,
|
||||
subscribes, and attaches the replacement standby on its STA before swapping it
|
||||
in; a setup failure leaves the previous standby and the current primary/subtag
|
||||
mode unchanged. The gateway advances its acknowledged fingerprint only after an
|
||||
OK reply, so a rejected update remains degraded and is retried.
|
||||
|
||||
`SubscribeAlarmsCommand.fallback_enabled` distinguishes an initially empty but
|
||||
discovery-enabled fallback from an intentionally alarm-manager-only
|
||||
subscription. In the former case the worker creates an empty standby that can be
|
||||
hot-swapped after discovery recovers. `ForceAlarmManager` leaves the field false
|
||||
and does not create a standby.
|
||||
|
||||
Because the list is fixed, the resolution is **graded** rather than merely
|
||||
logged (GWC-37). `ResolveAsync` returns an `AlarmWatchListResolution` carrying
|
||||
whether Galaxy Repository discovery was attempted and whether it failed, and the
|
||||
monitor folds the verdict into the alarm feed's `AlarmProviderStatus`:
|
||||
|
||||
- Zero targets while the effective mode can need them (`Auto` or `ForceSubtag`)
|
||||
sets `degraded = true` with reason `AlarmProviderReasons.SubtagWatchListEmpty`
|
||||
— the subtag provider covers no alarms.
|
||||
- Discovery enabled but failing adds
|
||||
`AlarmProviderReasons.SubtagDiscoveryFailed` and the failure message, so a
|
||||
Galaxy Repository outage at startup is a standing status rather than one log
|
||||
line emitted possibly weeks before the failover that needs the list.
|
||||
- If the worker rejects a material update, the monitor retains the previous
|
||||
acknowledged fingerprint and reports
|
||||
`AlarmProviderReasons.SubtagWatchListStale` with the rejection diagnostic.
|
||||
The next reconciliation or failover edge retries the same resolved targets.
|
||||
|
||||
`degraded` on the status is therefore the OR of "the subtag provider is the one
|
||||
serving" and "the subtag watch-list cannot cover anything". In `Auto` the second
|
||||
can be true while the mode is still `ALARMMGR`: the live feed is healthy and the
|
||||
standby behind it is empty. The dashboard renders that combination as the amber
|
||||
`Alarm Manager (no subtag standby)` badge rather than the failover label, so the
|
||||
badge never names the wrong live provider. `ForceAlarmManager` never builds a
|
||||
subtag consumer, so its watch-list is not graded at all.
|
||||
|
||||
Each target's canonical `AlarmFullReference` is composed as
|
||||
`Galaxy!{area}.{reference}` (literal `Galaxy` provider). The `{area}` is the
|
||||
@@ -908,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
|
||||
@@ -939,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
|
||||
@@ -962,6 +1051,18 @@ Every transition and snapshot record carries `degraded = true` and
|
||||
`source_provider = ALARM_PROVIDER_MODE_SUBTAG`. Clients that require full
|
||||
fidelity must wait for failback to the alarm manager.
|
||||
|
||||
### Retrigger transitions
|
||||
|
||||
`ALARM_TRANSITION_KIND_RETRIGGER` is reserved in the contract and the worker
|
||||
never emits it, in either provider mode. The alarmmgr XML reports only the
|
||||
four states `UNACK_ALM`, `ACK_ALM`, `UNACK_RTN`, and `ACK_RTN`, so a condition
|
||||
that returns to alarm after clearing (`*_RTN` → `*_ALM`) looks the same as a
|
||||
new raise, and `AlarmRecordTransitionMapper.MapTransition` reports it as
|
||||
`ALARM_TRANSITION_KIND_RAISE`. The subtag source has no re-alarm counter to
|
||||
observe either. The worker does not infer a retrigger from timing or
|
||||
from a raise that follows a clear, because the gateway forwards only
|
||||
transitions its source reports.
|
||||
|
||||
### Provider mode reflection
|
||||
|
||||
When `FailoverAlarmConsumer` switches between providers, it raises
|
||||
|
||||
+127
-2
@@ -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,126 @@ 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 treats `ArgumentException` from the MXAccess call itself as an
|
||||
interop failure alongside `COMException`, and reports the HRESULT the exception carries.
|
||||
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.
|
||||
|
||||
## Item handle reuse
|
||||
|
||||
`AlarmAckWriter` correlates an ack with its `OnWriteComplete` by the
|
||||
(server handle, item handle) pair, because the callback carries no per-write id.
|
||||
After an unconfirmed ack it drops the `AckMsg` item and re-adds it so the next
|
||||
ack waits on a new handle that a late completion for the old one cannot match.
|
||||
That only works if MXAccess does not hand the same number out again.
|
||||
|
||||
`ItemHandleReuseProbeTests` (Skip-gated, worker tests) checked this on windev
|
||||
(`10.100.0.48`, Galaxy `DEV`) on 2026-10-20 against
|
||||
`TestMachine_001.TestAlarm001.AckMsg`, without writing:
|
||||
|
||||
| Sequence on one registration (server handle 1) | Item handles returned |
|
||||
|---|---|
|
||||
| `AddItem` + `Advise`, then `UnAdvise` + `RemoveItem`, five times | 1, 2, 3, 4, 5 |
|
||||
| `AddItem`, then `RemoveItem`, five times | 6, 7, 8, 9, 10 |
|
||||
| Add and remove `AckMsg`, add `Acked`, add `AckMsg` again | 11, then 12 (`Acked`), 13 |
|
||||
| Add `AckMsg` twice while both are held | 14, 15 |
|
||||
|
||||
Within a registration the numbers increase and a freed number is never reused,
|
||||
so the drop-and-re-add mitigation holds. Numbering is per registration:
|
||||
`Unregister` + `Register` on the same COM object returned server handle 2 and
|
||||
item handle 1, and a new `LMXProxyServerClass` returned server handle 1 and
|
||||
item handle 1 again. The writer is not exposed to that restart because its
|
||||
teardown detaches its `OnWriteComplete` handler from the old COM object and
|
||||
clears that server handle's completion entries before it creates a new one.
|
||||
No per-handle generation counter is needed.
|
||||
|
||||
|
||||
@@ -138,7 +138,9 @@ would otherwise let any peer throttle a key it does not hold — and it joins th
|
||||
partition only when the presented token is validly shaped
|
||||
(`mxgw_<keyId>_<secret>`, key id at most 64 characters), with at most 32 key-id
|
||||
partitions per address before the overflow collapses onto that address's fallback
|
||||
partition. An over-limit state admits one probe per
|
||||
partition. That peer-layer collapse does not exempt a validly shaped key id from
|
||||
the cross-peer aggregate: its failures still feed and consult the key-id aggregate,
|
||||
whose own cardinality is bounded by the configured LRU cap. An over-limit state admits one probe per
|
||||
`ApiKeyFailureProbeIntervalSeconds` through to the real verifier and refuses
|
||||
everything else with `ResourceExhausted` before the store read, so a legitimate
|
||||
holder presenting the correct secret always reaches the constant-time compare and
|
||||
|
||||
+16
-1
@@ -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:
|
||||
|
||||
@@ -180,6 +180,21 @@ Supported constraints are:
|
||||
| `read_historized_only` | Read/subscription commands must target objects with historized attributes. |
|
||||
| `dashboard_tags` | Dashboard event-visibility tags. **Not a data-access constraint** — see below. |
|
||||
|
||||
`browse_subtrees` is applied by `GatewayBrowseScopeProvider`, the host seam the
|
||||
shared `ZB.MOM.WW.GalaxyRepository` package calls through `IGalaxyBrowseScopeProvider`;
|
||||
it reads the constraint off the ambient per-call identity that
|
||||
`GatewayGrpcAuthorizationInterceptor` pushed earlier in the same call. An empty
|
||||
`browse_subtrees` list is the library's "no scoping" signal, so the provider has to
|
||||
distinguish two states that look alike (GWC-38): an authenticated key with no browse
|
||||
constraint is legitimately unscoped and returns the empty list, whereas **no ambient
|
||||
identity at all** means the interceptor did not run before the Galaxy service and the
|
||||
request's scope cannot be established. In that second case, with
|
||||
`MxGateway:Authentication:Mode = ApiKey`, the provider logs a warning naming the broken
|
||||
ordering invariant and fails the call with `PermissionDenied` rather than serving the full
|
||||
hierarchy — a narrowing control's silent absence is invisible in normal operation, so it is
|
||||
made loud instead. Under `Mode = Disabled` an absent identity is the expected steady state
|
||||
(nothing authenticates, so no key and no constraints exist) and browse stays unscoped.
|
||||
|
||||
`dashboard_tags` is the one member of the blob that constrains nothing on the
|
||||
gRPC data path. No read, write, browse, or subscribe check consults it, and
|
||||
`HasReadConstraints` / `HasWriteConstraints` deliberately ignore it: adding a tag
|
||||
|
||||
@@ -72,6 +72,23 @@ carries the equivalent guard for the Go module: it refuses to create a
|
||||
`ClientVersion` already equals `X.Y.Z` (CLI-21/CLI-39), so a forgotten
|
||||
version bump fails the tag instead of shipping a mismatched module.
|
||||
|
||||
Both of those guards only fire when someone actually runs a publish.
|
||||
CLI-46 found the same hazard recurring silently *between* publishes: a
|
||||
real bug fix (the pypi 0.2.0 `connect()` fix above, commit `a8f86b5`) plus
|
||||
later wire-surface changes shipped into the tree while every version
|
||||
constant still read the already-published number, and nothing recorded
|
||||
that the registry artifact and the in-repo source had diverged.
|
||||
`scripts/check-client-versions.ps1` is the PR-time half of that guard —
|
||||
wired into CI (`.gitea/workflows/ci.yml`, the `portable` job, next to the
|
||||
codegen freshness checks): for each client it finds the latest publish tag
|
||||
(`clients/go/vX.Y.Z` for Go — the only client with a git tag at all; .NET,
|
||||
Python, Rust, and Java publish straight to a package registry via
|
||||
`pack-clients.ps1 -Publish` with no corresponding git tag, so the check
|
||||
reports "no tags found" and passes for those four today) and fails if
|
||||
`clients/<lang>` differs from that tag while the version constant still
|
||||
equals the tag's version. A client with no publish tag is not a
|
||||
violation — there is no "since the last publish" window to check yet.
|
||||
|
||||
As of 2026-08-07 (CLI-39) all five clients — plus `ZB.MOM.WW.MxGateway.Contracts`,
|
||||
which releases in lockstep with the .NET Client — moved to **0.2.0**,
|
||||
converging on one number after four of the five had drifted onto the
|
||||
@@ -111,6 +128,29 @@ confirmed present through the Gitea package API after the push, and
|
||||
`go list -m` resolves the Go tag. These are the numbers a future release
|
||||
bumps off.
|
||||
|
||||
**The published pypi `0.2.0` artifact has a broken `connect()` (CLI-46).**
|
||||
Its `connect()` built the `grpc.aio` channel inside `asyncio.to_thread`, and
|
||||
a `grpc.aio` channel binds to the event loop current on the *constructing*
|
||||
thread, so every non-stub connection raised `RuntimeError: There is no
|
||||
current event loop in thread` — TST-24's first real-wire run caught it.
|
||||
Fixed in-tree by commit `a8f86b5` (2026-08-10): `resolve_channel_security`
|
||||
now runs off-loop, `create_channel` runs on-loop. That fix, plus later
|
||||
wire-surface additions (`d9ea8a8`, `c748361`), shipped after the 2026-08-07
|
||||
publish while the version constants still read the already-published
|
||||
0.2.0/0.2.1 numbers above — see `clients/python/README.md` for the
|
||||
user-facing warning. **0.2.0 is superseded by 0.2.1; do not install it.**
|
||||
|
||||
On 2026-08-25 the superseding release shipped from commit
|
||||
`5776021970cbbe59813ba57579abe00d9fc61a46`. Published coordinates on
|
||||
`gitea.dohertylan.com` are `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, `pypi`
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, `cargo`
|
||||
`zb-mom-ww-mxgateway-client` **0.2.1**, and `maven`
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. The Go module
|
||||
tag `clients/go/v0.2.1` points to that same commit. Every exact package
|
||||
coordinate returned HTTP 200 through the Gitea package API after publication,
|
||||
and `go list -m` resolved `v0.2.1` to the tagged commit.
|
||||
|
||||
## .NET
|
||||
|
||||
The .NET client uses .NET 10 and references
|
||||
|
||||
@@ -40,6 +40,26 @@ the corresponding MXAccess `AddItem`, `Advise`, `UnAdvise`, and `RemoveItem`
|
||||
calls sequentially on the session STA and preserves input order in the result
|
||||
list.
|
||||
|
||||
The shared alarm command model includes `SubscribeAlarms` and
|
||||
`UpdateAlarmWatchList`. `SubscribeAlarmsCommand.fallback_enabled` makes an empty
|
||||
initial watch list explicitly fallback-capable, allowing discovery recovery to
|
||||
populate a standby without restarting the primary alarm-manager subscription.
|
||||
`UpdateAlarmWatchListCommand.watch_list` is the complete replacement list. The
|
||||
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`
|
||||
@@ -94,6 +114,15 @@ only and does not share types with `mxaccess_gateway.proto`. See
|
||||
[Galaxy Repository Browse](./GalaxyRepository.md) for the RPC catalog and
|
||||
behavior.
|
||||
|
||||
The retained proto remains the client-generation source of truth while the
|
||||
gateway serves the wire-identical descriptor from the
|
||||
`ZB.MOM.WW.GalaxyRepository` package. `GalaxyContractParityTests` converts both
|
||||
loaded descriptors to `FileDescriptorProto`, removes source locations, and
|
||||
normalizes the C# namespace and file name before comparing deterministic bytes.
|
||||
Its mutation test also requires drift failures to identify the first differing
|
||||
protobuf message. Any parity failure must be resolved before publishing either
|
||||
Contracts or clients.
|
||||
|
||||
Generated C# output is written to `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`. Do not
|
||||
hand-edit generated files.
|
||||
|
||||
@@ -140,6 +169,13 @@ Run the focused contract tests after changing either `.proto` file:
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter ProtobufContractRoundTripTests
|
||||
```
|
||||
|
||||
When changing `galaxy_repository.proto` or the served Galaxy Repository
|
||||
package, also run the retained-vs-served parity guard:
|
||||
|
||||
```bash
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter GalaxyContractParityTests
|
||||
```
|
||||
|
||||
The full solution build also regenerates the C# contracts before compiling
|
||||
gateway and test projects:
|
||||
|
||||
|
||||
@@ -66,6 +66,23 @@ declaration-ordered JSON; and the row sits alone on its own line for Go and for
|
||||
Rust's `--jsonl`, but inside an `events` array for Python and for Rust's
|
||||
aggregate `--json`.
|
||||
|
||||
Alarm-feed status rows deliberately preserve each CLI's established JSON
|
||||
mapping while making false values explicit in every text formatter:
|
||||
|
||||
| CLI | Healthy provider text | Clearing snapshot text | JSON false-value shape |
|
||||
|-----|-----------------------|------------------------|------------------------|
|
||||
| `mxgw-dotnet` | `provider-status mode=Unspecified degraded=false reason=""` | `snapshot-status truncated=false` | `{"providerStatus": {}}` / `{"snapshotStatus": {}}` |
|
||||
| `mxgw-go` | `provider-status mode=ALARM_PROVIDER_MODE_UNSPECIFIED degraded=false reason=""` | `snapshot-status truncated=false` | `{"providerStatus":{}}` / `{"snapshotStatus":{}}` |
|
||||
| `mxgw-py` | same JSON row as `--json` | same JSON row as `--json` | `{"providerStatus": {}}` / `{"snapshotStatus": {}}`, emitted one row at a time |
|
||||
| `mxgw-java` | `provider-status mode=ALARM_PROVIDER_MODE_UNSPECIFIED degraded=false reason=` | `snapshot-status truncated=false` | `{"providerStatus":{}}` / `{"snapshotStatus":{}}` |
|
||||
| `mxgw-rs` | explicit `degraded=false` summary | explicit `truncated=false` summary | always includes `"degraded":false` / `"truncated":false` |
|
||||
|
||||
For the protobuf-JSON rows, an absent proto3 boolean means `false`; Rust's
|
||||
always-explicit booleans are semantically equivalent. Python `stream-alarms`
|
||||
defaults to an unbounded feed and emits each row immediately; a positive
|
||||
`--max-messages` value provides a bounded sample and includes both status
|
||||
preamble rows in the count.
|
||||
|
||||
## Integration Gate
|
||||
|
||||
Cross-language smoke execution is opt-in. Runners should skip the matrix unless
|
||||
|
||||
+53
-11
@@ -90,9 +90,9 @@ detach-grace and replay-retention windows are on by default, the reconnect
|
||||
surface is a trust boundary — a retained session outlives the stream that opened
|
||||
it. The session records the opening key id (`GatewaySession.OwnerKeyId`) and
|
||||
every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless
|
||||
the caller's key id matches the owner. Gating on the `event` scope alone was
|
||||
rejected: it would let any `event`-scoped key that learned a session id attach to
|
||||
another key's retained session and receive its replayed and live data. The check
|
||||
the caller's key id matches the owner. Gating on the `events:read` scope alone was
|
||||
rejected: it would let any `events:read`-scoped key that learned a session id
|
||||
attach to another key's retained session and receive its replayed and live data. The check
|
||||
runs before any subscriber is attached, so a foreign key never touches the
|
||||
replay ring. Admin-scope override is deferred.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. The bypass is **read-only** — it satisfies a requirement including the `Viewer` role but never `AdminOnly`, so API-key CRUD and session Close/Kill stay closed — and since SEC-40 it does **not** reach the alarm feed: `/alarms` and `/hubs/alarms` carry `MxGateway.Dashboard.Alarms`, a policy built from a requirement type the bypass handler does not handle, so an anonymous loopback caller is denied the page, denied the hub, and not offered the Alarms link. The alarm monitor is gateway-central and its messages carry no session provenance, so alarm metadata cannot be tag-scoped the way session events are — requiring a real role claim is the narrowing that exists. Sign in (or use `DisableLogin` in dev, which mints a genuine principal) to see alarms; no setting re-opens the feed to the bypass. The same exclusion applies to `Authentication:Mode=Disabled`. |
|
||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||
| `MxGateway:Dashboard:CookieName` | _(unset)_ | Dashboard auth cookie name. Leave unset (null/blank) to use the resolved default: `__Host-MxGatewayDashboard` when `RequireHttpsCookie` is `true` (Secure cookie, so the `__Host-` prefix's Secure/no-Domain/`Path=/` guarantees apply), else the plain `MxGatewayDashboard` (a `__Host-` cookie without Secure is silently dropped by browsers). Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. An explicit override always wins over the `__Host-` default. Changing it signs out existing dashboard sessions on next deploy. |
|
||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||
@@ -202,7 +202,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
|
||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror** — `DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub** — `AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for the value text of each **successfully read** tag; a failed read keeps its `-` placeholder (there was no value to suppress, and the row's error is left untouched, so the two cannot contradict each other). Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. |
|
||||
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys must be **short** LDAP group names — the CN / first-RDN value, e.g. `GwAdmin`, not `ou=GwAdmin,ou=groups,dc=zb,dc=local` — matched case-insensitively. The shared `ZB.MOM.WW.Auth.Ldap` provider delivers a user's groups already stripped to short names, so a full-DN key can never match and the group silently maps to nothing. (The mapper does try the full literal string before falling back to the leading-RDN value, but on the live login path both branches see the same short name; the fallback only matters to non-library callers of the `IGroupRoleMapper<string>` seam.) Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only) — matched ordinally by the startup validator, so the spelling is exact and `Admin` is rejected. A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
|
||||
| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` — short LDAP group names (CN / first-RDN value), matched case-insensitively; a full-DN key never matches the pre-stripped groups the LDAP provider returns. Values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. |
|
||||
| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` — short LDAP group names (CN / first-RDN value), matched case-insensitively; a full-DN key never matches the pre-stripped groups the LDAP provider returns. Values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. **Applies to session event streams only:** the alarm feed (`/alarms`, `/hubs/alarms`) is deliberately *not* tag-scoped — the alarm monitor is gateway-central and session-less, so an `AlarmFeedMessage` carries no session or owning key to intersect a tag grant against. Alarm metadata (reference, state, severity, category, area, acknowledging operator) is therefore galaxy-wide for every authenticated dashboard role, regardless of tag grant; alarm *values* still follow `ShowTagValues`. What SEC-40 narrowed there is admission rather than scope: the environment bypasses (`AllowAnonymousLocalhost`, `Authentication:Mode=Disabled`) no longer receive the feed at all. **Revocation:** the ACL re-resolves a caller's grant from their LDAP group claims against this map on every subscribe, so removing an entry takes effect at the next subscribe after the gateway restart that rebinds options (options bind once at startup); a change to the user's LDAP group membership takes effect when their principal is rebuilt — at re-login for cookie sessions, within one 5-minute hub-token lifetime for token clients. An already-open subscription is never re-checked and lives until it drops. |
|
||||
| `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. |
|
||||
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
|
||||
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
|
||||
@@ -439,13 +439,22 @@ behavior.
|
||||
| `MxGateway:Alarms:Enabled` | `false` | Gates the gateway's always-on central alarm monitor. When `true`, the gateway opens one gateway-owned worker session dedicated to alarms, caches the active-alarm set, and fans it out to every client through the `StreamAlarms` RPC — no client opens its own session to see alarms. |
|
||||
| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\<machine>\Galaxy!<area>` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\<MachineName>\Galaxy!<DefaultArea>` if `DefaultArea` is set. |
|
||||
| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. |
|
||||
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. |
|
||||
| `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.
|
||||
|
||||
`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
|
||||
|
||||
The `Fallback` sub-section controls how the alarm feed selects between the
|
||||
@@ -453,17 +462,17 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover. `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
|
||||
| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover (and is the one mode whose subtag watch-list is never graded, because no subtag consumer is built). `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
|
||||
| `MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold` | `3` | Number of consecutive wnwrap COM failures (`COMException` or failure HRESULT from `Subscribe` / `GetXmlCurrentAlarms2`) before the monitor switches to subtag mode. Floored at 1. |
|
||||
| `MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds` | `30` | While in subtag mode, how often (in seconds) the monitor probes the wnwrap provider to detect recovery. Floored at 1. |
|
||||
| `MxGateway:Alarms:Fallback:FailbackStableProbes` | `3` | Number of consecutive clean wnwrap probes required before the monitor switches back to the alarm manager. Floored at 1. |
|
||||
| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. |
|
||||
| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. A failed query is not fatal — the monitor continues with whatever configuration contributed — but with `Mode` `Auto` or `ForceSubtag` the resulting coverage gap is reported on the alarm feed's `AlarmProviderStatus` (`degraded = true` with a `Subtag watch-list is empty` / `Galaxy Repository alarm discovery failed` reason) and shown on the dashboard as the amber `Alarm Manager (no subtag standby)` badge, instead of only a startup log line. |
|
||||
| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. This area is **not** used to compose a Repository-derived alarm's canonical `Galaxy!{area}.{reference}`: each discovered alarm uses its object's real Galaxy area (discovered via `gobject.area_gobject_id`), so the reference's group matches what the native alarmmgr emits. `Discovery:Area` / `DefaultArea` is used as the composition area only for explicit `IncludeAttributes` entries, which carry no discovered area. |
|
||||
| `MxGateway:Alarms:Fallback:Discovery:IncludeAttributes` | _(empty)_ | Explicit MXAccess attribute paths to add to the subtag watch-list, supplementing (or replacing, when `UseGalaxyRepository` is `false`) the Repository-derived list. |
|
||||
| `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:
|
||||
|
||||
@@ -274,7 +274,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
|
||||
| Hub | Path | Producer | Payload | Routing |
|
||||
|---|---|---|---|---|
|
||||
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick, but only while at least one client is connected (see "Idle gating" below); new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
|
||||
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition` / `provider_status` / `snapshot_status`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. |
|
||||
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition` / `provider_status` / `snapshot_status`) | Guarded by `MxGateway.Dashboard.Alarms`, not `HubClients`: a real authenticated dashboard role is required, so the `Authentication:Mode=Disabled` and anonymous-localhost bypasses are excluded (SEC-40). Connected clients auto-join `__alarms__`; all admitted clients receive every message — the feed is galaxy-wide and carries no session provenance, so no per-caller scoping applies (see "Alarm-feed visibility"). Publisher auto-reconnects every 5s on stream faults. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. |
|
||||
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. `SubscribeSession` is gated by `IDashboardSessionAcl` (SEC-25 / TST-15): a denied caller gets a `HubException`, is not joined to the group, and is not registered as a viewer, so the mirror stays off for a session nobody is legitimately watching. The same ACL gates the in-process seam the session-details page uses, so neither path is the weaker one. Value redaction remains an independent layer — it bounds what a *permitted* subscriber sees. |
|
||||
|
||||
### Default cadences
|
||||
@@ -556,6 +556,14 @@ re-asserts the truncation verdict as the reconcile baseline. If `MxGateway:Alarm
|
||||
false the central monitor never starts, and the page says so instead of showing
|
||||
an empty list with no explanation.
|
||||
|
||||
The page carries `[Authorize(Policy = MxGateway.Dashboard.Alarms)]` rather than relying on the
|
||||
`Viewer` policy `MapRazorComponents<App>()` applies to every component route (SEC-40), so it
|
||||
needs a real authenticated dashboard role: the `Authentication:Mode=Disabled` and
|
||||
anonymous-localhost bypasses reach every other read-only page but not this one. It is the same
|
||||
policy `AlarmsHub` carries — the two read one central monitor and must not disagree about who
|
||||
may see it — and the side rail hides the Alarms link for callers the policy denies. What a
|
||||
permitted caller sees is galaxy-wide and not tag-scoped; see "Alarm-feed visibility".
|
||||
|
||||
### Live data source
|
||||
|
||||
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
||||
@@ -706,14 +714,25 @@ Implemented behavior:
|
||||
generic credential-rejected message;
|
||||
- antiforgery tokens guard the login and logout POSTs.
|
||||
|
||||
Three authorization policies are registered:
|
||||
Four authorization policies are registered:
|
||||
|
||||
- `MxGateway.Dashboard.Viewer` — Razor component routes. Satisfied by Admin or
|
||||
Viewer.
|
||||
- `MxGateway.Dashboard.Admin` — Admin-only write surfaces (API-key CRUD).
|
||||
- `MxGateway.Dashboard.HubClients` — SignalR hubs. Accepts the dashboard
|
||||
cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades
|
||||
where the cookie can't be forwarded).
|
||||
- `MxGateway.Dashboard.HubClients` — the snapshot and events SignalR hubs. Accepts
|
||||
the dashboard cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket
|
||||
upgrades where the cookie can't be forwarded).
|
||||
- `MxGateway.Dashboard.Alarms` — the alarm feed: `AlarmsHub` and the `/alarms`
|
||||
page. Same two schemes as `HubClients`, but built from
|
||||
`AuthenticatedDashboardRoleRequirement` instead of
|
||||
`DashboardAuthorizationRequirement` (SEC-40). Because `DashboardAuthorizationHandler`
|
||||
handles only the latter type, the read-only environment bypasses below cannot
|
||||
satisfy this policy — see "Alarm-feed visibility".
|
||||
|
||||
The first three requirement types are satisfiable by the environment bypasses;
|
||||
`MxGateway.Dashboard.Alarms` and the mounted Secrets UI's `secrets:manage` are not,
|
||||
both for the same structural reason — the bypass handler is typed to a requirement
|
||||
they do not use.
|
||||
|
||||
The in-process page feeds carry no authentication of their own, and need none:
|
||||
`MapRazorComponents<App>()` applies `RequireAuthorization(ViewerPolicy)` to the
|
||||
@@ -733,12 +752,30 @@ caller's granted tags, ordinal-ignore-case. A session's tags are inherited from
|
||||
owning API key's `--dashboard-tags` constraint and are immutable for the session's
|
||||
life, so a subscribe-time decision cannot go stale while the subscription lives and
|
||||
no per-event re-check is needed. A Viewer's grant comes from
|
||||
`Dashboard:GroupToTag` applied to their LDAP groups, stamped as
|
||||
`zb:dashboardtag` claims at cookie login and re-resolved (not copied) at hub-token
|
||||
mint, so the token's 5-minute lifetime bounds how long a revoked grant survives. A
|
||||
principal carrying no tag claims — anonymous localhost included — is an
|
||||
empty-grant Viewer. The mirror's value redaction is an independent layer: it bounds
|
||||
what a permitted subscriber sees, not who may subscribe.
|
||||
`Dashboard:GroupToTag` applied to their LDAP groups. Since SEC-41 the ACL **re-resolves**
|
||||
that grant on every subscribe, from the caller's `mxgateway:ldap_group` claims against the
|
||||
currently bound `GroupToTag` map — the same one `DashboardGroupTagMapping.MapGroupsToTags`
|
||||
call the hub-token mint makes — rather than trusting the `zb:dashboardtag` claims stamped
|
||||
into the cookie at login. Those stamped claims were resolved under whatever map was bound
|
||||
when the user signed in, and the auth cookie outlives the gateway restart that applies a
|
||||
`GroupToTag` edit, so trusting them let a revoked grant survive the restart meant to remove
|
||||
it. The stamped claims remain the **fallback** for a principal carrying no group claims,
|
||||
which in practice means the hub-token principal: the token payload carries roles and tags
|
||||
but no LDAP groups, and the mint already re-resolved them. That fallback cannot re-open the
|
||||
cookie hole, because `DashboardAuthenticator` stamps group and tag claims from one group
|
||||
list — a cookie principal holding a tag claim always holds the groups it came from, and a
|
||||
login yielding no groups yields no tags. A principal carrying neither kind of claim —
|
||||
anonymous localhost included — is an empty-grant Viewer. The mirror's value redaction is an
|
||||
independent layer: it bounds what a permitted subscriber sees, not who may subscribe.
|
||||
|
||||
**Revocation bounds.** Three different revocations propagate on three different clocks, and
|
||||
the difference is intrinsic to where each fact lives:
|
||||
|
||||
| Revocation | Takes effect |
|
||||
|---|---|
|
||||
| A `GroupToTag` entry removed from config | At the next subscribe after the gateway restart that rebinds options — identically on both seams, since both re-resolve against current options. `GatewayOptions` binds once at startup, so the restart is what applies the edit; nothing further is needed. |
|
||||
| An LDAP group-membership revocation | When the principal is next rebuilt: at re-login for the cookie seam, bounded by the cookie's sliding 8-hour idle life (renewable by activity); within one 5-minute token lifetime for the hub-token seam. Neither seam re-queries LDAP per subscribe. |
|
||||
| Either, for an **already-open** subscription | Never — the subscription lives until it drops. The ACL is a subscribe-time gate by design (session tags are immutable, so no per-event re-check is warranted); closing an in-flight stream on a grant change would need a revocation channel the dashboard does not have. Force it by closing the session, or by restarting the gateway, which tears down every circuit and hub connection. |
|
||||
|
||||
Two environmental bypasses still apply, both scoped to **read-only** access:
|
||||
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
||||
@@ -746,11 +783,50 @@ Two environmental bypasses still apply, both scoped to **read-only** access:
|
||||
role (`AnyDashboardRole`) but **never** the `AdminOnly` requirement. Destructive/admin
|
||||
surfaces (API-key CRUD, session Close, worker Kill) still demand a real `Administrator`
|
||||
role claim, so the "anonymous localhost is read-only" contract holds at the policy layer
|
||||
rather than only in downstream service re-checks. Remote requests (outside the `Disabled`
|
||||
rather than only in downstream service re-checks. Since SEC-40 the bypasses also do not
|
||||
reach the **alarm feed** (`MxGateway.Dashboard.Alarms`) — read-only is not the same as
|
||||
"may see the whole plant's alarm metadata"; see "Alarm-feed visibility" below. Remote requests (outside the `Disabled`
|
||||
bypass) always require an authenticated principal carrying at least the Viewer role. The
|
||||
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
||||
added upstream, `DashboardAuthorizationHandler.IsLoopbackRequest()` must be revisited.
|
||||
|
||||
### Alarm-feed visibility
|
||||
|
||||
The alarm feed is **not** tag-scoped, and that is a deliberate, bounded decision rather than
|
||||
an oversight in the SEC-25 ACL.
|
||||
|
||||
The gateway's alarm monitor is central and session-less: one subscription expression, one
|
||||
`IGatewayAlarmService` feed, consumed by `AlarmsHubPublisher` (which re-broadcasts every
|
||||
message to the `__alarms__` group) and by the `/alarms` page in process. An `AlarmFeedMessage`
|
||||
carries no session id and no owning API key, so there is nothing for the per-session tag
|
||||
intersection to intersect — a tag ACL cannot be applied to it without inventing provenance the
|
||||
feed does not have. Deriving a filter from the caller's tags would also mean equating a
|
||||
visibility tag with an alarm-reference prefix, which `GroupToTag` deliberately does not do
|
||||
(tags are visibility labels, never data-access constraints).
|
||||
|
||||
So **alarm metadata stays galaxy-wide for every authenticated dashboard Viewer**: alarm
|
||||
reference (a full tag path), state, severity, priority, category, area, and acknowledging
|
||||
operator are all visible to any real dashboard role, regardless of which sessions their tag
|
||||
grant covers. Alarm *values* (`current_value` / `limit_value`) are still stripped when
|
||||
`Dashboard:ShowTagValues` is false (the default), on the hub seam as on the events seam.
|
||||
|
||||
What SEC-40 tightened is the entry gate rather than the scope. Both readers of the monitor —
|
||||
`AlarmsHub` and the `/alarms` page — carry `MxGateway.Dashboard.Alarms`, whose requirement type
|
||||
the environment-bypass handler does not handle. Consequences:
|
||||
|
||||
- `MxGateway:Authentication:Mode = Disabled` and anonymous localhost (`AllowAnonymousLocalhost`,
|
||||
default `true`) **no longer receive the alarm feed at all**. They keep read-only access to every
|
||||
other dashboard page; `/alarms` and `/hubs/alarms` deny, and the side rail hides the Alarms link
|
||||
rather than offering a dead end.
|
||||
- `Dashboard:DisableLogin` auto-login is unaffected: it mints a genuinely authenticated principal
|
||||
holding both roles, so it satisfies the policy like any signed-in user.
|
||||
- Gating only the hub was rejected: the page reads the same monitor in process, so the disclosure
|
||||
would have been one navigation away.
|
||||
|
||||
Operators who *want* the alarm feed on an unauthenticated local console must sign in (or, in a dev
|
||||
environment, set `Dashboard:DisableLogin=true`); there is deliberately no config flag that re-opens
|
||||
the feed to the bypasses.
|
||||
|
||||
### DisableLogin dev bypass
|
||||
|
||||
`MxGateway:Dashboard:DisableLogin` (default `false`) is a third bypass for
|
||||
@@ -824,13 +900,16 @@ the deliberate mitigation: it bounds that exposure window without the cost of a
|
||||
revocation store. It now bounds a stale *tag* grant the same way: the token carries
|
||||
the tags resolved from the caller's LDAP groups at mint time, so removing a
|
||||
`GroupToTag` entry takes effect for token-authenticated hub connections within one
|
||||
lifetime. That 5-minute staleness bound covers token-authenticated connections only:
|
||||
a cookie principal carries the `zb:dashboardtag` claims stamped at login for the
|
||||
cookie's whole life, so for cookie-authenticated (in-process page) subscriptions a
|
||||
revoked `GroupToTag` grant takes effect at the user's next login, not within five
|
||||
minutes. That is where the per-session ACL's revocation need landed — a jti
|
||||
denylist stays deferred, since the short lifetime already bounds every grant the
|
||||
token carries.
|
||||
lifetime. Since SEC-41 the cookie seam is no longer the weaker one: the ACL
|
||||
re-resolves a cookie principal's grant from its group claims against current
|
||||
options on every subscribe, so a `GroupToTag` edit takes effect on both seams at
|
||||
the next subscribe after the restart that rebinds options — the stamped
|
||||
`zb:dashboardtag` claims are consulted only for a principal with no group claims
|
||||
to re-resolve from, i.e. the hub-token principal itself. What the token's lifetime
|
||||
still bounds uniquely is a stale **role** set and a stale LDAP **group** list, since
|
||||
both are copied into the payload at mint. See the revocation-bounds table under
|
||||
"Authentication And Authorization". A jti denylist stays deferred — the short
|
||||
lifetime already bounds every claim the token carries.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -593,9 +593,19 @@ Pending command handling:
|
||||
Timeouts should not assume the COM call stopped. A timed-out command may still
|
||||
finish inside the worker.
|
||||
|
||||
On timeout the client also forwards a `WorkerCancel` carrying the abandoned
|
||||
correlation id, best-effort: the gateway has stopped waiting, but the worker has
|
||||
not stopped working, and the worker owns a single STA. `WorkerPipeSession` routes
|
||||
On timeout **and on caller cancellation** the client also forwards a
|
||||
`WorkerCancel` carrying the abandoned correlation id, best-effort: the gateway
|
||||
has stopped waiting, but the worker has not stopped working, and the worker owns
|
||||
a single STA. Caller cancellation — a per-call deadline shorter than the
|
||||
gateway's command timeout, or a client disconnect cancelling the RPC's token — is
|
||||
the more common of the two paths, and under a retry storm it is exactly the
|
||||
backlog the cancel exists to shorten, so both arms send it; the pending command
|
||||
is failed as `CallerCancelled` rather than `GatewayShutdown` so metrics and logs
|
||||
name the real cause (both still map to gRPC `Cancelled`). The send is gated on
|
||||
the same ownership check as the timeout arm — only the arm that removed the
|
||||
pending entry sends, so a reply that won the race is never chased — and is
|
||||
skipped when the client's own stop token has fired, since the outbound channel is
|
||||
being completed and every pending command is failed anyway. `WorkerPipeSession` routes
|
||||
the cancel to `CancelCommand`, which drops the correlation from the STA queue if
|
||||
it has not started and replies `Canceled` for it. A cancel that arrives after the
|
||||
command reached MXAccess is a no-op — there is no way to abort an in-flight COM
|
||||
@@ -606,9 +616,10 @@ the worker reads the command and then its cancel and drops it before execution.
|
||||
|
||||
Cancels ride the same outbound channel as commands, whose capacity is
|
||||
`MaxPendingCommands + 4`: the reserve above the pending-command limit is what
|
||||
absorbs them, so a burst of timeouts stays bounded and cannot deadlock the
|
||||
enqueue path. Failing to send the cancel is logged at debug and never replaces
|
||||
the `CommandTimeout` the caller is owed.
|
||||
absorbs them, so a burst of timeouts or cancellations stays bounded and cannot
|
||||
deadlock the enqueue path. Failing to send the cancel is logged at debug and
|
||||
never replaces the `CommandTimeout` — or the `OperationCanceledException` — the
|
||||
caller is owed.
|
||||
|
||||
Cancellation outranks the deadline. When a caller's token is canceled around the
|
||||
same time the timeout fires, the command is reported as canceled
|
||||
|
||||
@@ -116,7 +116,7 @@ Optional live smoke variables:
|
||||
| `MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME` | `ZB.MOM.WW.MxGateway.IntegrationTests` | Client name passed to `Register`. |
|
||||
| `MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS` | `15` | Maximum wait for the first `OnDataChange` (also used for the `OnWriteComplete` round-trip and the abnormal-exit fault transition). |
|
||||
| `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER` | `admin` | ArchestrA user name passed to `AuthenticateUser` before the `WriteSecured` parity step. |
|
||||
| `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` | `admin123` | Password paired with the user above. Never logged; the test asserts the value does not appear in the WriteSecured diagnostic message. |
|
||||
| `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` | `password` | Password paired with the user above (matches the shared GLAuth `admin` user's dev password — see `glauth.md`). Never logged; the test asserts the value does not appear in the WriteSecured diagnostic message. |
|
||||
|
||||
The test output includes session id, worker process id, command status,
|
||||
HRESULT/status diagnostics, event sequence and handles, close status, and worker
|
||||
@@ -650,11 +650,13 @@ runtime because the x86 Worker cannot build on Linux:
|
||||
the tier is down, not the change**: an unreachable windev fails the job (never skips), so
|
||||
if it goes red without a related code change, check the tier — `ssh windev`, read
|
||||
`C:\build\mxaccessgw-ci\ci.log` — before assuming a real test failure.
|
||||
- **`nightly-windev`** (Linux runner, scheduled `0 6 * * *`) — re-runs the full x86 build +
|
||||
Worker.Tests on windev, then the live-MXAccess smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`)
|
||||
and a full `slnx` build (which surfaces Windows-only analyzer diagnostics such as
|
||||
`xUnit1030`). Gated `if: github.event_name == 'schedule'`, so it never gates a push. On
|
||||
failure it opens a Gitea issue via the Actions token, since nobody watches the Actions page.
|
||||
- **`nightly-windev`** (Linux runner, scheduled `0 6 * * *`) — runs, in order, the x86 Worker
|
||||
build, `Worker.Tests`, a full `slnx` build (which surfaces Windows-only analyzer diagnostics
|
||||
such as `xUnit1030`), the net10 gateway suite, and the live-MXAccess smoke
|
||||
(`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`). The checkout and all five tiers share the windev
|
||||
host lock, and the first nonzero native exit stops the sequence and propagates to the CI job.
|
||||
Gated `if: github.event_name == 'schedule'`, so it never gates a push. On failure it opens a
|
||||
Gitea issue via the Actions token, since nobody watches the Actions page.
|
||||
|
||||
### Runner capacity is shared and finite
|
||||
|
||||
|
||||
+32
-4
@@ -84,15 +84,42 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
||||
|
||||
An accepted gRPC command payload can still be too large for the worker pipe: the envelope built around it must fit `MxGateway:Worker:MaxMessageBytes`, which is validated at startup to sit at least a fixed envelope-overhead reserve above `MaxGrpcMessageBytes` (see the headroom rule in [Gateway Configuration](./GatewayConfiguration.md)) so this should not occur for a conformant payload, but if it does, `WorkerClient` raises `WorkerClientException(CommandTooLarge)` and `Invoke` reports `ResourceExhausted` for that command — the session is not faulted, so a client can retry with a smaller payload without reopening the session.
|
||||
|
||||
Selected control commands have additional public behavior beyond their protobuf shape:
|
||||
|
||||
| Command | Public behavior |
|
||||
|---|---|
|
||||
| `DrainEvents` | Returns a destructive diagnostic snapshot of the worker's outbound event queue. `max_events = 0` selects the default cap; positive values may not exceed `GatewayContractInfo.MaxDrainEventsPerCommand` (10,000). The worker also applies a serialized-byte budget derived from the negotiated frame limit, so a reply may contain fewer events than the requested count. When either cap leaves queued events, `MxCommandReply.diagnostic_message` reports the truncation and callers repeat `DrainEvents` until an empty reply. Events included in a drain are removed from the worker queue and therefore do not later appear on the normal session `StreamEvents` path; the background stream drain may concurrently consume other events first. |
|
||||
|
||||
### `StreamEvents`
|
||||
|
||||
`StreamEvents` is a server-streaming RPC. The handler delegates the full pipeline to `IEventStreamService` and just forwards each `MxEvent` onto the response stream. Keeping the channel and producer/consumer machinery out of the handler means cancellation, exception mapping, and metric bookkeeping live in one place.
|
||||
|
||||
### `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, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an exception from the worker's own code rather than from an MXAccess call: interop failures are classified only when the `AuthenticateUser`, `AddItem`, `Advise` or `Write` call itself raises them, and an `ArgumentNullException` or `ArgumentOutOfRangeException` is never classified because the interop does not raise them for an HRESULT) | `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`
|
||||
|
||||
@@ -100,7 +127,7 @@ An `OK` response means the alarm provider accepted the acknowledgement, not that
|
||||
|
||||
### `QueryActiveAlarms`
|
||||
|
||||
`QueryActiveAlarms` is a server-streaming, **session-less** RPC that returns a point-in-time snapshot of the currently-active alarm set. Unlike `StreamAlarms`, it does not stay attached for live transitions — it writes the snapshot and completes, which clients use after a reconnect to reseed alarm state or to reconcile alarms that may have been missed during a transport blip. It is served from the always-on `GatewayAlarmMonitor` cache (`IGatewayAlarmService.CurrentAlarms`), so no worker session is opened. The handler does not run through `MxAccessGrpcRequestValidator`; it only null-checks the request inline, then streams one `ActiveAlarmSnapshot` per cached alarm, honouring cancellation between writes. When `alarm_filter_prefix` is non-empty, only alarms whose `alarm_full_reference` starts with that prefix (ordinal `StartsWith`) are emitted; an empty prefix returns the full set. Exceptions are translated by the same `MapException` path as the other handlers. The RPC requires the `event` scope (`GatewayScopes.EventsRead`), the same scope as `StreamEvents` and `StreamAlarms`.
|
||||
`QueryActiveAlarms` is a server-streaming, **session-less** RPC that returns a point-in-time snapshot of the currently-active alarm set. Unlike `StreamAlarms`, it does not stay attached for live transitions — it writes the snapshot and completes, which clients use after a reconnect to reseed alarm state or to reconcile alarms that may have been missed during a transport blip. It is served from the always-on `GatewayAlarmMonitor` cache (`IGatewayAlarmService.CurrentAlarms`), so no worker session is opened. The handler does not run through `MxAccessGrpcRequestValidator`; it only null-checks the request inline, then streams one `ActiveAlarmSnapshot` per cached alarm, honouring cancellation between writes. When `alarm_filter_prefix` is non-empty, only alarms whose `alarm_full_reference` starts with that prefix (ordinal `StartsWith`) are emitted; an empty prefix returns the full set. Exceptions are translated by the same `MapException` path as the other handlers. The RPC requires the `events:read` scope (`GatewayScopes.EventsRead`), the same scope as `StreamEvents` and `StreamAlarms`.
|
||||
|
||||
#### Provider status on the alarm feed
|
||||
|
||||
@@ -211,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. | — |
|
||||
|
||||
@@ -282,7 +310,7 @@ StatusCode statusCode = exception.ErrorCode switch
|
||||
};
|
||||
```
|
||||
|
||||
`WorkerClientException` follows the same pattern: `CommandTimeout` becomes `DeadlineExceeded`, `GatewayShutdown` becomes `Cancelled`, `InvalidState` becomes `FailedPrecondition`, `ProtocolViolation` becomes `Internal`, `CommandTooLarge` becomes `ResourceExhausted`, and unmapped codes fall through to `Unavailable`.
|
||||
`WorkerClientException` follows the same pattern: `CommandTimeout` becomes `DeadlineExceeded`, `GatewayShutdown` and `CallerCancelled` both become `Cancelled`, `InvalidState` becomes `FailedPrecondition`, `ProtocolViolation` becomes `Internal`, `CommandTooLarge` becomes `ResourceExhausted`, and unmapped codes fall through to `Unavailable`. `CallerCancelled` is the caller's own token firing — a per-call deadline shorter than the gateway command timeout, or a disconnect — and is separate from `GatewayShutdown` only so metrics and logs stop attributing those calls to a shutdown that did not happen; the status the client observes is the same.
|
||||
|
||||
## Event Streaming Model
|
||||
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ Observable gauges are pull-based; the `Meter` invokes the supplied callback when
|
||||
|------------|--------------|-------------|
|
||||
| `mxgateway.sessions.open` | `_openSessions` | Currently open sessions tracked by `SessionManager`. |
|
||||
| `mxgateway.workers.running` | `_workersRunning` | Worker clients in a running state. |
|
||||
| `mxgateway.events.worker_queue.depth` | `_workerEventQueueDepthSources` (summed on demand) | Undelivered worker events held by `WorkerClient` — staged *and* queued (GWC-24) — summed across every live client at collection time (GWC-30). Each client owns an interlocked counter incremented when the read loop stages an event and decremented when the consumer reads it, and registers it as a gauge source for its lifetime, so a backlog stuck in a staging channel is visible and concurrent sessions add up instead of overwriting one another. |
|
||||
| `mxgateway.events.worker_queue.depth` | `_workerEventQueueDepthSources` (summed on demand) | Undelivered worker events held by `WorkerClient` — staged *and* queued (GWC-24) — summed across every live client at collection time (PERF-02). Each client owns an interlocked counter incremented when the read loop stages an event and decremented when the consumer reads it, and registers it as a gauge source for its lifetime, so a backlog stuck in a staging channel is visible and concurrent sessions add up instead of overwriting one another. |
|
||||
| `mxgateway.events.grpc_stream_queue.depth` | `_eventStreamBacklogSources` (summed on demand) | Live backlog buffered across every active `EventStreamService` subscriber, summed from the subscribers' channel `Count` at collection time. |
|
||||
|
||||
## Snapshot Shape
|
||||
@@ -111,7 +111,7 @@ The scalar fields mirror the counters and gauges. The four dictionaries provide
|
||||
- `EventsBySession` keys by `sessionId`; entries are removed via `RemoveSessionEvents` when a session closes so the map does not grow without bound.
|
||||
- `RetryAttemptsByArea` keys by the resilience `area` tag, e.g. `worker_startup`.
|
||||
|
||||
`EventsReceived` is read with `Interlocked.Read(ref _eventsReceived)` because `EventReceived` increments it via `Interlocked.Increment` outside the lock to keep the event-ingestion path non-blocking. `CommandsStarted`, `CommandsSucceeded`, `CommandsFailed`, and `CommandFailuresByMethod` are read the same way: the command counters run two-to-three times per gRPC call, so they are recorded with `Interlocked` and a `ConcurrentDictionary` rather than under `_syncRoot` (GWC-30). The two queue depths are pulled from their registered sources before the lock is taken, since those delegates reach into subscriber channels and worker clients.
|
||||
`EventsReceived` is read with `Interlocked.Read(ref _eventsReceived)` because `EventReceived` increments it via `Interlocked.Increment` outside the lock to keep the event-ingestion path non-blocking. `CommandsStarted`, `CommandsSucceeded`, `CommandsFailed`, and `CommandFailuresByMethod` are read the same way: the command counters run two-to-three times per gRPC call, so they are recorded with `Interlocked` and a `ConcurrentDictionary` rather than under `_syncRoot` (PERF-02). The two queue depths are pulled from their registered sources before the lock is taken, since those delegates reach into subscriber channels and worker clients.
|
||||
|
||||
## Recording Sites
|
||||
|
||||
|
||||
@@ -90,13 +90,22 @@ Expected command-line arguments:
|
||||
--protocol-version <version>
|
||||
```
|
||||
|
||||
Expected protected environment values:
|
||||
Expected launch environment values (the shared names are defined by
|
||||
`WorkerLaunchEnvironment` in Contracts):
|
||||
|
||||
```text
|
||||
MXGATEWAY_WORKER_NONCE=<random nonce>
|
||||
MXGATEWAY_WORKER_LOG_CONTEXT=<optional context>
|
||||
MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS=<milliseconds>
|
||||
MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS=<milliseconds>
|
||||
MXGATEWAY_EVENT_QUEUE_CAPACITY=<events>
|
||||
MXGATEWAY_ALARM_POLL_INTERVAL_MS=<milliseconds>
|
||||
MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH=<alarms>
|
||||
```
|
||||
|
||||
The nonce is protected bootstrap material. The remaining values are derived
|
||||
from the public `MxGateway:Worker:*` and `MxGateway:Alarms:*` configuration
|
||||
keys documented in [Worker Process Launcher](./WorkerProcessLauncher.md).
|
||||
|
||||
Startup sequence:
|
||||
|
||||
1. Parse command-line arguments.
|
||||
@@ -124,6 +133,9 @@ starts the STA, or touches MXAccess:
|
||||
| `2` | `InvalidArguments` | Required arguments are missing or unknown arguments are present. |
|
||||
| `3` | `InvalidProtocolVersion` | `--protocol-version` is not numeric or does not match the supported worker protocol. |
|
||||
| `4` | `MissingNonce` | `MXGATEWAY_WORKER_NONCE` is absent or empty. |
|
||||
| `5` | `PipeConnectionFailed` | An `IOException` or `TimeoutException` escapes the pipe client. |
|
||||
| `6` | `ProtocolViolation` | A `WorkerFrameProtocolException` escapes the pipe client. |
|
||||
| `7` | `UnhandledException` | An exception reaches the `AppDomain.UnhandledException` hook (see `docs/WorkerBootstrap.md`). |
|
||||
|
||||
Bootstrap logs use `WorkerConsoleLogger` key/value output. `WorkerLogRedactor`
|
||||
redacts fields whose names indicate nonce, secret, password, token,
|
||||
@@ -261,12 +273,33 @@ is still responsive. Shutdown marks the runtime as closing, wakes the pump,
|
||||
rejects new commands, cancels queued work, uninitializes COM on the STA, and
|
||||
waits for the thread to exit.
|
||||
|
||||
`ProcessQueuedCommands` pumps pending Windows messages between queued work
|
||||
items whenever more work remains, not only after the whole queue drains. A
|
||||
burst of queued commands would otherwise hold every inbound COM callback
|
||||
(`OnDataChange`, `OnWriteComplete`) behind the entire backlog.
|
||||
|
||||
If the STA loop itself throws after `Start` has returned (a failed
|
||||
`MsgWaitForMultipleObjectsEx`, or an exception escaping a dispatched message),
|
||||
`StaRuntime` records the exception as `LoopFault`, fails every later
|
||||
`InvokeAsync` with an `InvalidOperationException` wrapping it (never the
|
||||
graceful `StaRuntimeShutdownException`), cancels queued work, and invokes the
|
||||
handler registered through `RegisterLoopFaultHandler` — unless a requested
|
||||
shutdown is already in progress, in which case the death is part of a
|
||||
deliberate teardown and is not reported. `MxAccessStaSession` registers a
|
||||
handler that records a `StaHung` `WorkerFault` on the event queue (diagnostic
|
||||
text carries the exception type and HRESULT only, never the message),
|
||||
so the IPC drain loop sends the fault to the gateway and ends the pipe session
|
||||
and the worker process. Before this, a post-start loop death was stored in the
|
||||
startup-exception field that only `Start` reads, leaving a live worker whose
|
||||
commands queued forever. Startup failures still surface from `Start` itself.
|
||||
|
||||
### Inner Completion Waits
|
||||
|
||||
Two commands hold the STA while waiting for a COM event they just provoked: the
|
||||
unary write path waits for its `OnWriteComplete`
|
||||
(`MxAccessWriteCompletionCache.TryWaitForCompletion`), and `ReadBulk` waits per
|
||||
tag for the first `OnDataChange` (`MxAccessValueCache.TryWaitForUpdate`). Both
|
||||
(`MxAccessWriteCompletionCache.TryWaitForCompletion`), and `ReadBulk` waits for
|
||||
the first `OnDataChange` of every snapshot tag
|
||||
(`MxAccessValueCache.TryWaitForUpdates`). Both
|
||||
run the same loop shape as the outer pump, for the same reason — the event they
|
||||
are waiting for *is* a Windows message, so the thread must keep dispatching to
|
||||
receive it:
|
||||
@@ -288,7 +321,7 @@ The idle slice is a Win32 wait (`StaWaitHelper.WaitForSignalOrMessages`), never
|
||||
`Thread.Sleep`. A sleeping STA pumps no messages, so a sleep-polled loop could
|
||||
only dispatch the awaited COM event at poll-tick granularity while stalling
|
||||
*every other* event for the same tick — up to 1.5 s for a write completion and
|
||||
up to `timeout_ms` per tag for `ReadBulk`. The Win32 wait returns the instant a
|
||||
up to `timeout_ms` for `ReadBulk`. The Win32 wait returns the instant a
|
||||
message needs pumping, so the apartment dispatches continuously for the whole
|
||||
wait. Each cache also sets an `AutoResetEvent` from its update path (outside the
|
||||
cache lock) so a cross-thread producer wakes the waiter immediately; in the live
|
||||
@@ -308,15 +341,90 @@ nothing wakes the wait — a process with no STA message queue (unit tests drive
|
||||
these caches from ordinary threads, standing in for the STA by updating the
|
||||
cache from a fake `pumpStep`) must not block for a full poll interval. Timeouts,
|
||||
deadline math, and return values are unchanged by the wait mechanism: an expired
|
||||
write wait still yields the empty-`statuses` unconfirmed reply, and an expired
|
||||
per-tag `ReadBulk` wait still reports its own timeout.
|
||||
write wait still yields the empty-`statuses` unconfirmed reply, and every
|
||||
`ReadBulk` tag that did not deliver before the deadline still reports its own
|
||||
timeout.
|
||||
|
||||
`ReadBulk` runs its snapshot lifecycle in phases so the STA is held for at most
|
||||
one `timeout_ms` per command, not one per tag: (1) every tag is validated and
|
||||
probed against the cached-and-advised fast path; (2) every remaining tag is
|
||||
added and advised; (3) one wait with a shared deadline collects the first
|
||||
`OnDataChange` per snapshot handle and returns as soon as all have delivered;
|
||||
(4) every snapshot handle is unadvised and removed, best-effort. Per-tag
|
||||
results are unchanged: request order, the blank-address failure, the cached
|
||||
result, an `AddItem` failure (item handle 0) or `Advise` failure (its own item
|
||||
handle) failing only that tag, and the `ReadBulk timed out after <n> ms waiting
|
||||
for first OnDataChange.` message for each tag that never delivered. The cache
|
||||
probes all run before any snapshot `AddItem`, so a later tag in the same
|
||||
request can never be served from an earlier tag's snapshot subscription.
|
||||
When MXAccess returns the same item handle for two tags in one batch (a
|
||||
duplicate address), the handle is advised, unadvised, and removed once, and
|
||||
every duplicate receives the shared outcome. Phases 2 and 4 make one or two
|
||||
COM calls per tag without waiting, so they call the pump step every
|
||||
`ReadBulkPumpInterval` (16) tags; a large batch therefore keeps dispatching
|
||||
inbound callbacks and refreshing `LastStaActivityUtc` instead of drifting
|
||||
toward `HeartbeatStuckCeiling`.
|
||||
|
||||
The write wait's budget is `MxGateway:Worker:WriteCompletionWaitMilliseconds`
|
||||
(default 1500). It is a bounded hold on the STA per unary write, so deployments
|
||||
(default 1500). It applies to `Write`, `Write2`, `WriteSecured`, and
|
||||
`WriteSecured2` and is a bounded hold on the STA per single-item write, so deployments
|
||||
whose write workload is effectively fire-and-forget — no consumer reads the
|
||||
reply's `statuses` — can lower it, or set `0` to skip the wait entirely and
|
||||
reply on acceptance alone.
|
||||
|
||||
**Write-completion correlation is recency-based, not per-call.** MXAccess's
|
||||
`OnWriteComplete` callback carries no correlation token — only the server/item
|
||||
handle pair — so `MxAccessCommandExecutor` correlates a completion to a write
|
||||
by *version*: it snapshots `MxAccessWriteCompletionCache.CurrentVersion` for
|
||||
the handle as a baseline before the COM call, then reports the first
|
||||
completion whose version exceeds that baseline as the write's outcome
|
||||
(`AwaitWriteCompletion`). This is exact for the common case, but if an earlier
|
||||
write to the same handle times out unconfirmed — its `OnWriteComplete` still
|
||||
undelivered when the wait gives up — and the caller immediately issues another
|
||||
write to the same tag, the stale callback can dispatch during the second
|
||||
write's pump-wait and be reported as *its* outcome. The executor mitigates the
|
||||
most common instance of this (`CaptureCompletionBaseline`) by running one
|
||||
`pumpStep()` immediately before capturing the baseline, so a completion already
|
||||
queued for the handle folds into the baseline instead of being available to
|
||||
ride the next write's reply; this closes the gap for a callback that arrived
|
||||
before the new write started, but a callback that MXAccess queues after that
|
||||
pump step and before the COM call returns (or during the wait itself) is still
|
||||
indistinguishable from the new write's own completion. Full closure is not
|
||||
possible without an MXAccess-side correlation token, which does not exist —
|
||||
this is a property of the COM surface, not a gap this worker introduced (a
|
||||
native client correlating by handle has the identical ambiguity). The exposure
|
||||
is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus`
|
||||
stay acceptance-only regardless of which completion (if any) was attributed.
|
||||
|
||||
**The alarm ack writer narrows the window for acknowledgements.**
|
||||
`AlarmAckWriter` registers its own MXAccess server with its own
|
||||
`MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm
|
||||
(least-recently-used, capped at 256). After an ack ends unconfirmed it marks the
|
||||
item dirty, and the next ack to that alarm removes and re-adds the item so the
|
||||
write waits on a fresh item handle the stale completion cannot match. This
|
||||
relies on MXAccess never reusing an item handle number within one
|
||||
registration, which a live probe confirmed (see "Item handle reuse" in
|
||||
[Alarm Probe Findings](./AlarmProbeFindings.md)). Every
|
||||
drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`)
|
||||
also removes that handle's completion entry, and tearing down the registration
|
||||
removes every entry for its server handle, so the cache does not grow by one
|
||||
entry per dropped handle for the life of the worker. The writer records a
|
||||
completion only for an item handle it currently holds on its live
|
||||
registration, so a late `OnWriteComplete` for a dropped handle cannot recreate
|
||||
its entry.
|
||||
|
||||
## Live alarm watch-list replacement
|
||||
|
||||
An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a
|
||||
`FailoverAlarmConsumer`, even when initial discovery supplied no targets. The
|
||||
handler retains the subscription expression so `UpdateAlarmWatchList` can build
|
||||
and subscribe a new subtag consumer entirely on the session STA. The failover
|
||||
consumer attaches the replacement's transition callback before swapping the
|
||||
standby reference, then detaches and disposes the old standby. If construction,
|
||||
subscription, or attachment fails, it disposes the replacement and preserves
|
||||
the old standby. The active-provider selector is never reset, so primary mode
|
||||
stays primary and subtag mode immediately delegates to the new standby.
|
||||
|
||||
## COM Creation
|
||||
|
||||
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
|
||||
@@ -608,7 +716,20 @@ The worker records plain and supervisory advice separately only after the COM
|
||||
call returns normally. Successful `UnAdvise` removes all tracked advice for the
|
||||
server and item pair because the public MXAccess cleanup method has no plain
|
||||
versus supervisory selector. Successful `RemoveItem` and `Unregister` also clear
|
||||
related advice state from the worker registry. Failed advice and cleanup calls
|
||||
related advice state from the worker registry, and evict the per-handle
|
||||
caches the event sink fills: `RemoveItem` drops the handle pair's
|
||||
`MxAccessValueCache` and `MxAccessWriteCompletionCache` entries, and
|
||||
`Unregister` drops every entry recorded under the server handle (MXAccess
|
||||
removes the server's items with it, so the per-item eviction never runs for
|
||||
them). Without this, an `AddItem` → `Write` → `RemoveItem` churn grew the
|
||||
completion cache by one entry per cycle for the life of the worker.
|
||||
`AddItem`, `AddItem2`, and `AddBufferedItem` also drop both cache entries for
|
||||
the handle they receive when the registry does not already track it. An
|
||||
`OnDataChange` or `OnWriteComplete` that was queued when `RemoveItem` or
|
||||
`Unregister` ran is dispatched after the eviction and recreates the entry;
|
||||
when MXAccess later reuses that item id, `ReadBulk`'s cached path would
|
||||
otherwise serve the stale value as `was_cached = true`. A handle the
|
||||
registry still tracks belongs to a live item, so its entries are kept. Failed advice and cleanup calls
|
||||
leave registry state unchanged so diagnostics continue to reflect the last
|
||||
successful MXAccess-owned state transition.
|
||||
|
||||
@@ -842,6 +963,23 @@ the STA more than 75 s without pumping *should* fault — that is the ceiling's
|
||||
contract). The flag is named generically so any future non-dispatcher STA work
|
||||
reuses it.
|
||||
|
||||
A `PollOnce` that throws does not fault the session on the first failure. The
|
||||
poll loop counts consecutive failures and records the alarm-poll fault
|
||||
(`MxaccessEventConversionFailed`) only when the count reaches
|
||||
`MxAccessStaSession.DefaultAlarmPollFailureThreshold` (3, the same default as
|
||||
`AlarmFailoverConfig.consecutive_failure_threshold`); a successful poll resets
|
||||
the count. Each failure below the threshold writes one
|
||||
`level=Warning event=AlarmPollFailed` line to stderr carrying the exception
|
||||
type, HRESULT, and counts only — the exception message is left out because it
|
||||
can echo alarm or tag names. A single transient COM failure in
|
||||
`GetXmlCurrentAlarms2` used to fault the whole session, taking every data
|
||||
subscription with it; a persistent failure, including an STA-affinity
|
||||
regression that fails every tick, still faults within three poll intervals.
|
||||
A poll failure seen after the STA loop itself has died (`StaRuntime.LoopFault`
|
||||
is set) ends the poll loop without recording anything: the loop-fault handler
|
||||
has already recorded the `StaHung` fault, and the failure is only the
|
||||
fast-fail `InvokeAsync` returns afterwards.
|
||||
|
||||
The in-flight-command suppression itself is bounded by
|
||||
`WorkerPipeSessionOptions.HeartbeatStuckCeiling` (default 75 seconds = 5 ×
|
||||
`HeartbeatGrace`). The motivating case for the suppression is a legitimately
|
||||
|
||||
+3
-1
@@ -213,6 +213,8 @@ The repair transitions the monitor's reconcile broadcasts on the alarm feed (Rai
|
||||
|
||||
`AttachInternalEventSubscriber` enforces the same readiness gate as `AttachEventSubscriber` — a session (or worker) that is not `Ready` throws `SessionNotReady` *before* the distributor is constructed. A premature attach would otherwise start the pump against a source that throws, completing every subscriber with that error and latching the distributor for the session's whole lifetime.
|
||||
|
||||
The same one-distributor-per-session rule is enforced at the other end of the lifecycle. Session disposal disposes the distributor and latches the fact, so an attach that passed its readiness gate and then lost the CPU until the session was torn down throws `SessionNotReady` rather than constructing a replacement distributor. A replacement can never be pumped — the pump-start flag is a one-way latch — so its subscriber would block on an empty channel that is never completed until the client's own deadline expired, with no error, log or metric to explain it. Every attach entry point (`AttachEventSubscriber`, `AttachEventSubscriberWithReplay`, `AttachInternalEventSubscriber`) fails this way, and each rolls back the subscriber-count slot it took, so a caller that loses the race to teardown sees the same failure it would have seen a moment later from the readiness gate.
|
||||
|
||||
Sessions open with `MxGateway:Sessions:DefaultLeaseSeconds` (default 1800) added to the open timestamp. Unary client activity refreshes the lease by the same duration. `ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`. `SessionLeaseMonitorHostedService` runs that sweep every `MxGateway:Sessions:LeaseSweepIntervalSeconds` seconds (default 30).
|
||||
|
||||
#### Teardown parallelism
|
||||
@@ -245,7 +247,7 @@ A session that faults (for example, a slow single-subscriber client that overflo
|
||||
|
||||
A client that drops mid-stream reconnects by re-issuing `StreamEvents` with `StreamEventsRequest.after_worker_sequence` set to the last `worker_sequence` it observed. A non-zero `after_worker_sequence` means *resume*; `0` means *fresh stream* and behaves exactly as a first-time subscribe — no replay, no sentinel.
|
||||
|
||||
**Owner-scoped attach (security control).** `OpenSession` records the caller's API key id on the session as `GatewaySession.OwnerKeyId`. Every `StreamEvents` attach and reattach is owner-checked: `EventStreamService.StreamEventsAsync` compares the caller's key id (threaded from `GatewayGrpcAuthorizationInterceptor` via `MxAccessGatewayService.StreamEvents`) to the session owner and throws `SessionManagerException(PermissionDenied)` — surfaced as gRPC `PermissionDenied` — when they differ, before any subscriber is attached and before any replayed or live event is delivered. Possessing the `event` scope and knowing a session id is not enough. This is what makes the default-on detach-grace and replay-retention windows safe: without the check, any `event`-scoped key that learned a session id could attach to another key's retained session and receive its replayed and live data. A `null` owner (session opened with authentication disabled) matches only a `null` caller key.
|
||||
**Owner-scoped attach (security control).** `OpenSession` records the caller's API key id on the session as `GatewaySession.OwnerKeyId`. Every `StreamEvents` attach and reattach is owner-checked: `EventStreamService.StreamEventsAsync` compares the caller's key id (threaded from `GatewayGrpcAuthorizationInterceptor` via `MxAccessGatewayService.StreamEvents`) to the session owner and throws `SessionManagerException(PermissionDenied)` — surfaced as gRPC `PermissionDenied` — when they differ, before any subscriber is attached and before any replayed or live event is delivered. Possessing the `events:read` scope and knowing a session id is not enough. This is what makes the default-on detach-grace and replay-retention windows safe: without the check, any `events:read`-scoped key that learned a session id could attach to another key's retained session and receive its replayed and live data. A `null` owner (session opened with authentication disabled) matches only a `null` caller key.
|
||||
|
||||
On a resume, `EventStreamService.StreamEventsAsync` attaches through `GatewaySession.AttachEventSubscriberWithReplay`, which calls `SessionEventDistributor.RegisterWithReplay`. That method snapshots the session's replay ring for events newer than `after_worker_sequence` **and** registers the live subscriber inside a single `_replayLock` critical section. This atomicity is what makes the replay→live handoff free of gaps and duplicates: the pump appends each event to the replay ring (under `_replayLock`) before fanning it to subscriber channels, so relative to that one critical section every event is either in the replay snapshot or fanned into the freshly-registered live channel — never both observably, never neither.
|
||||
|
||||
|
||||
@@ -117,8 +117,13 @@ public static WorkerBootstrapResult Failure(WorkerExitCode exitCode, IEnumerable
|
||||
| `MissingNonce` | 4 | The `MXGATEWAY_WORKER_NONCE` environment variable is null, empty, or whitespace. |
|
||||
| `PipeConnectionFailed` | 5 | An `IOException` or `TimeoutException` escapes the pipe client. |
|
||||
| `ProtocolViolation` | 6 | A `WorkerFrameProtocolException` escapes the pipe client. |
|
||||
| `UnhandledException` | 7 | An exception reaches `AppDomain.CurrentDomain.UnhandledException` — thrown on a thread outside `WorkerApplication.Run`'s `try/catch` (a background task continuation, a timer, a COM callback thread). |
|
||||
|
||||
`InvalidArguments`, `InvalidProtocolVersion`, and `MissingNonce` originate in the parser; the others originate in `WorkerApplication.Run`'s `try/catch` around the pipe client.
|
||||
`InvalidArguments`, `InvalidProtocolVersion`, and `MissingNonce` originate in the parser; `UnhandledException` originates in the process-wide hook described below; the others originate in `WorkerApplication.Run`'s `try/catch` around the pipe client.
|
||||
|
||||
### Unhandled-exception hook
|
||||
|
||||
The production entry point, `WorkerApplication.Run(string[])`, subscribes `AppDomain.CurrentDomain.UnhandledException` before it parses arguments. The handler (`WorkerApplication.HandleUnhandledException`) logs one `WorkerUnhandledException` error line and calls `Environment.Exit(7)`. Without it, an exception on a thread that `Run` does not own ended the process with the CLR's crash code and no worker log line, so the gateway saw an unexplained exit. The line carries `exit_code`, `exception_type`, and `is_terminating` only: the exception message and stack are left out because they can carry tag values or credentials passed through a command, the same rule every other worker failure line follows. A failure inside the logger is swallowed so the exit always happens. The injectable `Run` overloads that tests call do not install the hook, because it is process-global.
|
||||
|
||||
## Environment Abstraction
|
||||
|
||||
|
||||
@@ -26,6 +26,23 @@ The launcher sets the nonce through the `MXGATEWAY_WORKER_NONCE` environment
|
||||
variable. The nonce is not included in `WorkerProcessCommandLine` so logs and
|
||||
diagnostics can report the launch command without exposing the secret.
|
||||
|
||||
All launch-variable names live in the shared Contracts
|
||||
`WorkerLaunchEnvironment` class and are consumed by both the launcher and the
|
||||
worker. The launcher always exports all six values:
|
||||
|
||||
| Environment variable | Gateway source | Worker default when missing/invalid |
|
||||
|---|---|---|
|
||||
| `MXGATEWAY_WORKER_NONCE` | `WorkerProcessLaunchRequest.Nonce` | None; bootstrap fails because the nonce is required |
|
||||
| `MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS` | `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` ms |
|
||||
| `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` | `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` ms |
|
||||
| `MXGATEWAY_EVENT_QUEUE_CAPACITY` | `MxGateway:Worker:EventQueueCapacity` | `10000` events |
|
||||
| `MXGATEWAY_ALARM_POLL_INTERVAL_MS` | `MxGateway:Alarms:PollIntervalMilliseconds` | `500` ms |
|
||||
| `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` | `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` alarms |
|
||||
|
||||
The write-completion wait applies to the four single-item write variants:
|
||||
`Write`, `Write2`, `WriteSecured`, and `WriteSecured2`. Bulk writes report COM
|
||||
acceptance per entry and do not wait for `OnWriteComplete` rows.
|
||||
|
||||
## Validation And Cleanup
|
||||
|
||||
Before starting the process, the launcher validates that the configured worker
|
||||
@@ -44,11 +61,8 @@ process remains alive. The policy is configured by
|
||||
`WorkerOptions.StartupProbeRetryDelayMilliseconds`; the retry counter is
|
||||
recorded as `mxgateway.retries.attempted` with `area=worker_startup`.
|
||||
|
||||
The launcher also passes
|
||||
`MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS` to the worker process from
|
||||
`WorkerOptions.PipeConnectAttemptTimeoutMilliseconds`. The worker uses that
|
||||
value as the per-attempt named-pipe connect timeout inside its own bounded
|
||||
Polly retry loop.
|
||||
The worker uses the exported pipe-attempt value inside its own bounded Polly
|
||||
retry loop.
|
||||
|
||||
If startup fails or exceeds `WorkerOptions.StartupTimeoutSeconds`, the launcher
|
||||
kills the worker process tree, disposes the process handle, disposes the
|
||||
|
||||
+13
-3
@@ -86,10 +86,16 @@ StaWorkItem<T> workItem = new(command, cancellationToken);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (loopFault is not null)
|
||||
{
|
||||
return Task.FromException<T>(new InvalidOperationException(
|
||||
"The worker STA loop terminated unexpectedly.",
|
||||
loopFault));
|
||||
}
|
||||
|
||||
if (shutdownRequested)
|
||||
{
|
||||
return Task.FromException<T>(
|
||||
new InvalidOperationException("The worker STA runtime is shutting down."));
|
||||
return Task.FromException<T>(new StaRuntimeShutdownException());
|
||||
}
|
||||
|
||||
commandQueue.Enqueue(workItem);
|
||||
@@ -101,7 +107,11 @@ return workItem.Task;
|
||||
|
||||
`StaWorkItem<T>` uses an `Interlocked.CompareExchange` on `started` so that exactly one of three outcomes happens: the STA thread executes the delegate, an external `CancellationToken` cancellation fires first, or `CancelBeforeExecution` runs during shutdown. `Execute` runs on the STA thread, sets `Completion` from `command()`, and propagates exceptions through `TrySetException` so the awaiting caller observes them.
|
||||
|
||||
`ProcessQueuedCommands` is the only consumer of the queue and runs on the STA thread, so each work item is guaranteed to execute on the apartment that owns the COM object.
|
||||
`ProcessQueuedCommands` is the only consumer of the queue and runs on the STA thread, so each work item is guaranteed to execute on the apartment that owns the COM object. It calls `PumpPendingMessages` between work items whenever more work is queued, so a backlog of commands cannot hold inbound COM callbacks until the whole queue drains.
|
||||
|
||||
### Loop failure after start
|
||||
|
||||
An exception thrown by the loop after `Start()` returned — `WaitForWorkOrMessages` reporting `WAIT_FAILED`, or an exception escaping a dispatched message — is not a startup failure, and nothing reads `startupException` after `Start()`. `ThreadMain` instead stores it as `LoopFault` under `gate`, so every later `InvokeAsync` fails immediately with an `InvalidOperationException` whose inner exception is the loop fault, and work already queued is cancelled by the `finally` block. It then invokes the handler registered with `RegisterLoopFaultHandler`; a handler registered after the loop died runs immediately on the registering thread. Neither happens when `Shutdown` was already requested, because a loop that throws during a deliberate teardown is not a worker fault. `MxAccessStaSession` registers a handler that records a `StaHung` `WorkerFault` on the event queue, which the IPC drain loop sends to the gateway before ending the pipe session and the worker process.
|
||||
|
||||
## Command Dispatch
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ Run `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` (expect 0 warnings) a
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameReader.cs` (comment only)
|
||||
|
||||
**Why:** On net48, `NamedPipeClientStream.ReadAsync` ignores its `CancellationToken` (`WorkerFrameReader.cs:109-111`). Fault-path exits (event-drain fault, oversized event, watchdog, heartbeat write failure) leave `readTask` pending; it is unblocked only when `WorkerPipeClient`'s `using` disposes the pipe, at which point it faults with `ObjectDisposedException`/`IOException` on a Task nobody observes (the finally at `:303-309` awaits only heartbeat and drain). The frame-pooling change (GWC-30) makes this sharper: the abandoned read owns the per-instance `_lengthPrefix` buffer and possibly a rented ArrayPool payload — the reader's single-consumer invariant holds today only because nothing ever reads again after abandonment.
|
||||
**Why:** On net48, `NamedPipeClientStream.ReadAsync` ignores its `CancellationToken` (`WorkerFrameReader.cs:109-111`). Fault-path exits (event-drain fault, oversized event, watchdog, heartbeat write failure) leave `readTask` pending; it is unblocked only when `WorkerPipeClient`'s `using` disposes the pipe, at which point it faults with `ObjectDisposedException`/`IOException` on a Task nobody observes (the finally at `:303-309` awaits only heartbeat and drain). The frame-pooling change (PERF-22) makes this sharper: the abandoned read owns the per-instance `_lengthPrefix` buffer and possibly a rented ArrayPool payload — the reader's single-consumer invariant holds today only because nothing ever reads again after abandonment.
|
||||
|
||||
**Spec — constraints, implementer designs within them:**
|
||||
1. **No unobserved faulted Task.** After the stream is disposed, `readTask`'s fault must be awaited/observed (reuse `ObserveBackgroundTaskStopAsync`'s timeout-and-log shape, `:312-348`) before `WorkerPipeClient.RunAsync` returns.
|
||||
|
||||
@@ -608,6 +608,24 @@ Commit anything found: `docs: remediation plan doc sweep`
|
||||
|
||||
---
|
||||
|
||||
## PERF-ID register
|
||||
|
||||
This plan's tasks were cited in code comments and test names as `GWC-30`/`GWC-31` before
|
||||
those labels were recognized as colliding with the `archreview` finding register — see
|
||||
cycle-3 finding `GWC-31` (`archreview/2026-08-19/10-gateway-core.md`). The comments were
|
||||
swept to the `PERF-nn` prefix below (`nn` = this doc's task number); `GWC-30`/`GWC-31` no
|
||||
longer appear in perf-pass comments. Citations of the *genuine* archreview `GWC-30`
|
||||
("Frame reader still allocates a fresh 4-byte prefix array per frame",
|
||||
`archreview/2026-07-12/10-gateway-core.md`) — the gateway-side
|
||||
`Server/Workers/WorkerFrameReader.cs` fix and its test — were left untouched; they refer
|
||||
to a real register entry, not this plan.
|
||||
|
||||
| PERF-ID | Task | Files |
|
||||
|---|---|---|
|
||||
| PERF-02 | Task 2 — Metrics: pull-gauge for worker queue depth, lock-free command counters | `Server/Workers/WorkerClient.cs` (`:44`, `:355`), `Server/Metrics/GatewayMetrics.cs` (`:46`, `:213`, `:284`, `:463`, `:524`), `Tests/Metrics/GatewayMetricsTests.cs` (`:22`, `:61`), `docs/Metrics.md` |
|
||||
| PERF-07 | Task 7 — WorkerClient: pooled-timer timeout, single sizing pass, `WorkerCancel` on timeout | `Server/Workers/WorkerClient.cs` (`:236`, `:253`), `Tests/Gateway/Workers/WorkerClientTests.cs` (`:163`, `:179`) |
|
||||
| PERF-22 | Task 22 — Worker frame writer/reader: pooled buffers | `Worker/Ipc/WorkerFrameReader.cs` (`:23`), `Worker/Ipc/WorkerFrameWriter.cs` (`:671`), `docs/plans/2026-08-15-deferred-remediation.md` (Task cross-reference) |
|
||||
|
||||
## Explicitly deferred (decided, not forgotten)
|
||||
|
||||
| Finding | Why deferred |
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Cycle 3 P2 And Follow-Up Design
|
||||
|
||||
**Date:** 2026-08-25
|
||||
|
||||
**Source:** `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Scope:** The 17 findings recorded as deferred P2 plus the three follow-ups recorded by the cycle-3 integration gate. `SEC-37` and `TST-32` remain external actions and are not part of this repository change.
|
||||
|
||||
## Current-State Reconciliation
|
||||
|
||||
The 2026-08-25 audit refreshed `origin/main` and checked every finding against the current tree. Sixteen P2 findings remain open. `WRK-34` is already complete in commit `3679e59` and needs a tracker correction rather than another code change. All three integration-gate follow-ups remain open:
|
||||
|
||||
- a live worker command and gateway path for refreshing an alarm subscription's subtag watch-list;
|
||||
- the full WRK-32 recency limitation in the public reply-field comment;
|
||||
- publication of the already-bumped client release train.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Preserve the existing worker STA and COM-affinity rules. Worker alarm-consumer construction, subscription changes, and disposal remain on the STA.
|
||||
2. Preserve the primary alarm-manager subscription during watch-list repair. A refresh must never unsubscribe and recreate the whole alarm feed.
|
||||
3. Make behavioral changes test-first and keep documentation in the same commit as the behavior it describes.
|
||||
4. Perform one protobuf regeneration wave for the watch-list command, the WRK-32 reply-field clarification, and the IPC-36 operator-facing comment correction.
|
||||
5. Publish clients only after every client-affecting change is integrated, all five client suites pass, the release commit is on `main`, and registry collision checks pass.
|
||||
6. Keep the canonical archreview tracker current at every batch boundary, including commit IDs and verification counts.
|
||||
|
||||
## Workstreams
|
||||
|
||||
### 1. Tracker And Low-Risk Currency
|
||||
|
||||
Correct WRK-34 to Done, repair the stale replay-watermark documentation (GWC-36), document the public `DrainEvents` command (IPC-38), reconcile `stillpending.md` (TST-33), wire the code-review README check into portable CI (TST-34), and replace the nonexistent `event` scope spelling with `events:read` (TST-36).
|
||||
|
||||
These edits form the first batch because they have a small conflict surface and establish an accurate tracker before code changes begin.
|
||||
|
||||
### 2. Gateway Correctness And Security
|
||||
|
||||
- **GWC-33:** stop faulted or closed worker clients from contributing frozen queue depth while retaining fault records for the configured grace period.
|
||||
- **GWC-35:** make snapshot reconciliation emit a Raise when a cached acknowledged alarm is replaced by an active unacknowledged instance.
|
||||
- **SEC-38:** consult and feed the cross-peer aggregate for every validly-shaped key ID, even when the per-peer key-partition cap routes the request through the fallback bucket. Aggregate cardinality remains bounded by the aggregate cache's existing eviction policy.
|
||||
- **SEC-39:** never let a null effective key ID clear the shared fallback partition.
|
||||
|
||||
Each change gets a focused regression test that first reproduces the filed failure mode.
|
||||
|
||||
### 3. Secondary Contracts
|
||||
|
||||
- **IPC-36:** move all six worker-launch environment-variable names into one net48-safe Contracts class, consume those constants on both sides, enumerate the launch contract in the launcher and worker design documentation, and test that the launcher sets every declared variable.
|
||||
- **IPC-37:** compare the retained gateway Galaxy descriptor with the descriptor served by the shared `ZB.MOM.WW.GalaxyRepository` package after normalizing source-only metadata.
|
||||
|
||||
This work makes the two non-protobuf peer contracts executable invariants rather than comments.
|
||||
|
||||
### 4. Client Alarm-Feed Parity
|
||||
|
||||
- **CLI-47:** explicitly render false-valued provider and snapshot status fields in .NET text output; document the deliberate protobuf-JSON omission behavior and Rust's explicit JSON shape.
|
||||
- **CLI-48:** add provider-status and clearing snapshot-status coverage in .NET, Go, and Java, including Go's missing `stream-alarms` test scaffold.
|
||||
- **CLI-49:** expose the two Go status payload aliases and update the alarm-feed alias documentation.
|
||||
- **CLI-50:** change Python `stream-alarms` from a one-message buffered sample to incremental streaming with an unbounded-by-default limit, while retaining timeout and explicit finite-limit behavior.
|
||||
|
||||
The Python change uses the same arrival-order streaming model as the other four CLIs. Documentation examples will request a finite bound when a bounded demonstration is desirable.
|
||||
|
||||
### 5. Live Alarm Watch-List Refresh
|
||||
|
||||
The complete periodic hot-swap design is selected over failover-edge-only refresh and unsubscribe/resubscribe.
|
||||
|
||||
#### Contract
|
||||
|
||||
Add an explicit worker command for updating an active alarm subscription's watch-list. Add an explicit subscription flag that distinguishes these cases when the initial list is empty:
|
||||
|
||||
- alarm-manager-only deployment with no fallback requested;
|
||||
- auto fallback configured, but discovery currently produced no targets.
|
||||
|
||||
The command is gateway-owned even though it travels through the shared `MxCommand` envelope. It returns a normal `MxCommandReply`; invalid mode/state or replacement-subscription failure returns a structured failure without destroying the existing subscription.
|
||||
|
||||
#### Worker
|
||||
|
||||
In auto mode, retain a failover composite even when fallback is enabled with an initially empty list. The composite retains its subscription expression and supports replacing its standby consumer on the STA:
|
||||
|
||||
1. construct the replacement standby;
|
||||
2. subscribe and warm it before altering the current child;
|
||||
3. attach transition forwarding;
|
||||
4. atomically replace the standby reference;
|
||||
5. dispose the previous standby;
|
||||
6. if standby is currently active, keep the mode on subtag and expose the replacement snapshot immediately.
|
||||
|
||||
If any pre-swap step fails, dispose the replacement and keep the old standby untouched. Forced alarm-manager mode rejects refresh as inapplicable. Forced subtag mode may replace its sole consumer through the same build-first/swap-second rule.
|
||||
|
||||
#### Gateway
|
||||
|
||||
Reuse the existing alarm reconciliation cadence; do not add another timer or configuration option. Resolve only when fallback can use subtags. Compare a canonical target fingerprint with the list last acknowledged by the worker, and send an update only after material change or recovery from an empty/failed resolution.
|
||||
|
||||
The failover edge still performs an immediate refresh attempt before publishing its coverage verdict. A successful update changes the recorded sent fingerprint, clears stale-coverage status, and triggers reconciliation. A failed update retains the previous fingerprint and publishes/logs the degraded reason.
|
||||
|
||||
This design repairs the original startup-outage case before failover whenever Galaxy Repository recovers during normal monitor operation, while preserving the warm primary and current provider mode.
|
||||
|
||||
### 6. Windows Gateway Coverage
|
||||
|
||||
**TST-35** extends windev `live` mode to run the net10 gateway suite after the serialized Worker tier and full solution build. The existing host lock remains the load-sensitivity control. Logs record the observed count rather than hard-coding the historical 879 or 1183 count as an invariant.
|
||||
|
||||
### 7. Protobuf Regeneration And Release
|
||||
|
||||
The watch-list command lands in the same regeneration wave as:
|
||||
|
||||
- WRK-32's explicit sequential stale-callback/recency limitation;
|
||||
- IPC-36's replacement of the internal environment-variable name with the public configuration key.
|
||||
|
||||
Regenerate Contracts and all five client trees according to `docs/Contracts.md` and `docs/ClientPackaging.md`, then run the codegen cleanliness checks and every affected suite.
|
||||
|
||||
After integration to `main`, publish .NET/Contracts, Python, Rust, and Go as `0.2.1`, and Java as `0.2.2`. Query each exact coordinate before publishing, never overwrite, create and push the Go module tag at the exact release commit, then query every registry coordinate again and update the packaging record and tracker with immutable release evidence.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
- macOS: clean `NonWindows.slnx` build and full gateway suite.
|
||||
- Focused gateway tests after each behavioral batch.
|
||||
- All five client build/test commands from `docs/ClientPackaging.md`.
|
||||
- `scripts/check-codegen.ps1`, client-version guard, and code-review README check.
|
||||
- windev: x86 Worker build plus Worker.Tests for worker/proto changes; `live` mode for TST-35 and the final integration gate.
|
||||
- Release: exact registry-coordinate checks and Go module resolution from the pushed tag.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
The continuation is complete only when all 16 open P2 findings and all three follow-ups are Done or explicitly blocked with evidence, WRK-34 is corrected in the tracker, every required verification is green, client releases are confirmed at their registries, and `archreview/2026-08-19/remediation/00-tracking.md` contains the commit and test evidence for every batch.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Cycle 3 P2 And Follow-Up Implementation Plan
|
||||
|
||||
> **For Codex:** REQUIRED SUB-SKILL: Use `executeplan` to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Close the 16 still-open cycle-3 P2 findings, correct WRK-34's tracker status, implement the three integration-gate follow-ups, and publish the completed client release train.
|
||||
|
||||
**Architecture:** Work proceeds in seven reviewable batches. Small tracker and documentation corrections land first; focused gateway, security, and secondary-contract fixes follow; client parity is completed before a single protobuf regeneration wave adds live alarm watch-list refresh and the remaining public comments; Windows CI is extended before the final windev gate; packages are published only from the integrated `main` release commit.
|
||||
|
||||
**Tech Stack:** .NET 10/C#, .NET Framework 4.8 x86, protobuf/Grpc.Tools, PowerShell 5.1/7, Go, Python/pytest, Rust/Cargo, Java/Gradle, Gitea Actions and package registries.
|
||||
|
||||
---
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Work only in `.worktrees/c3-p2-followups` on `codex/c3-p2-followups`.
|
||||
- Preserve the clean baseline recorded on 2026-08-20/25: `NonWindows.slnx` builds with zero warnings and the gateway suite passes 1183/1183.
|
||||
- Follow red-green-refactor for every behavior change. Documentation-only changes use an explicit failing grep/check as their red gate.
|
||||
- After every batch, update `archreview/2026-08-19/remediation/00-tracking.md` with status, commits, and exact verification output before committing.
|
||||
- Worker/protobuf changes require windev x86 verification before Done.
|
||||
|
||||
### Task 1: Batch H — Tracker And Documentation Currency
|
||||
|
||||
**Findings:** WRK-34 tracker correction, GWC-36, IPC-38, TST-33, TST-34, TST-36
|
||||
|
||||
**Files:**
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/EventSubscriberReplayAttachment.cs`
|
||||
- Modify: `docs/Grpc.md`
|
||||
- Modify: `stillpending.md`
|
||||
- Modify: `.gitea/workflows/ci.yml`
|
||||
- Modify: `REVIEW-PROCESS.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/Sessions.md`
|
||||
- Modify: `docs/DesignDecisions.md`
|
||||
|
||||
**Step 1: Capture the failing currency checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git grep -n '`event` scope' -- CLAUDE.md docs/
|
||||
rg -n 'not safe to rely|Phase 5, not started|6 \[LiveMxAccessFact\]|wire behavior is unverified|still scheduled.*Phase 4' stillpending.md
|
||||
rg -n 'regen-readme.py|check-code-reviews-readme' .gitea/workflows/ci.yml
|
||||
rg -n 'DrainEvents' docs/Grpc.md
|
||||
```
|
||||
|
||||
Expected: stale scope and pending claims are found; CI and `DrainEvents` checks find no intended entry.
|
||||
|
||||
**Step 2: Correct GWC-36 and TST-36**
|
||||
|
||||
Change `OldestAvailableSequence` to document retained-oldest or `highest observed + 1` when an empty ring has a gap, with `0` only when `Gap` is false. Replace prose references to the nonexistent ``event`` scope with ``events:read`` without renaming identifiers such as `GatewayScopes.EventsRead`.
|
||||
|
||||
**Step 3: Correct IPC-38 and TST-33**
|
||||
|
||||
Add a `DrainEvents` command row to `docs/Grpc.md` covering the 10,000-event count cap, byte cap, truncation diagnostic, drain-until-empty contract, and removal from the normal session event stream. Reconcile the five stale `stillpending.md` claims and stamp the file as a historical inventory whose live status comes from archreview trackers.
|
||||
|
||||
**Step 4: Wire TST-34**
|
||||
|
||||
Add this portable-job step next to the existing repository consistency checks:
|
||||
|
||||
```yaml
|
||||
- name: Code-reviews README consistency
|
||||
run: python code-reviews/regen-readme.py --check
|
||||
```
|
||||
|
||||
Qualify the interpreter note in `REVIEW-PROCESS.md`: Windows may require `python`; macOS/Linux normally use `python3`, while CI uses its configured `python`.
|
||||
|
||||
**Step 5: Re-run the checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 code-reviews/regen-readme.py --check
|
||||
git grep -n '`event` scope' -- CLAUDE.md docs/ || true
|
||||
rg -n 'not safe to rely|Phase 5, not started|6 \[LiveMxAccessFact\]|wire behavior is unverified|still scheduled.*Phase 4' stillpending.md || true
|
||||
rg -n 'DrainEvents' docs/Grpc.md
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx
|
||||
```
|
||||
|
||||
Expected: README check passes; stale greps return nothing; DrainEvents is documented; build has zero warnings/errors.
|
||||
|
||||
**Step 6: Update tracker and commit**
|
||||
|
||||
Mark the five P2 findings Done and WRK-34 Done (`3679e59`), record verification, then commit:
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/ci.yml REVIEW-PROCESS.md CLAUDE.md stillpending.md docs/Grpc.md docs/Sessions.md docs/DesignDecisions.md src/ZB.MOM.WW.MxGateway.Server/Sessions/EventSubscriberReplayAttachment.cs archreview/2026-08-19/remediation/00-tracking.md
|
||||
git commit -m "chore(GWC-36,IPC-38,TST-33,TST-34,TST-36): close cycle-3 currency gaps"
|
||||
```
|
||||
|
||||
### Task 2: Batch I — Gateway Correctness And Security
|
||||
|
||||
**Findings:** GWC-33, GWC-35, SEC-38, SEC-39
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs`
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs`
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAttachOrderTests.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ApiKeyFailureLimiter.cs`
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ApiKeyFailureLimiterTests.cs`
|
||||
- Modify: `docs/Authentication.md`
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Step 1: RED for GWC-33**
|
||||
|
||||
Add a worker-client test that stages an event, faults the client without disposing it, and asserts the aggregate queue-depth observable stops counting that client while the faulted object remains alive. Run:
|
||||
|
||||
```bash
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~WorkerClientTests
|
||||
```
|
||||
|
||||
Expected: the new assertion fails because the registration is disposed only by `DisposeAsync`.
|
||||
|
||||
**Step 2: GREEN for GWC-33**
|
||||
|
||||
Make queue-depth registration disposal idempotent and call it on both terminal transitions and disposal. Do not clear the faulted client's diagnostic state or shorten `FaultedGraceSeconds` retention. Re-run the WorkerClient filter and commit the focused change.
|
||||
|
||||
**Step 3: RED/GREEN for GWC-35**
|
||||
|
||||
Add a monitor test whose first reconcile contains `ActiveAcked` and whose next reconcile carries a newer `Active` instance. Assert one `Raise` repair carrying the incoming instance identity. Verify red, then generalize the both-present state delta:
|
||||
|
||||
```csharp
|
||||
AlarmTransitionKind kind = incoming.Value.CurrentState == AlarmConditionState.ActiveAcked
|
||||
? AlarmTransitionKind.Acknowledge
|
||||
: AlarmTransitionKind.Raise;
|
||||
```
|
||||
|
||||
Keep the existing duplicate suppression keyed by original raise timestamp. Re-run `GatewayAlarmMonitorAttachOrderTests`.
|
||||
|
||||
**Step 4: RED/GREEN for SEC-38**
|
||||
|
||||
Add a limiter test that fills all 32 peer key-ID partitions, then attacks one valid victim key ID through the fallback from multiple peers. Assert the victim aggregate trips. Change resolution/recording so every validly-shaped presented key ID consults and feeds its aggregate even if its peer partition fell back; continue using the resolved partition key for the peer layer and the existing capped aggregate eviction for cardinality. Update class remarks and `docs/Authentication.md`.
|
||||
|
||||
**Step 5: RED/GREEN for SEC-39**
|
||||
|
||||
Add a test that records fallback failures, calls `Reset` with a null effective key ID, and asserts the fallback remains tracked. Tighten ownership:
|
||||
|
||||
```csharp
|
||||
if (effectiveKeyId is not null
|
||||
&& string.Equals(effectiveKeyId, partition.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
RemovePartition(partitionKey);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Verify, track, and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerClientTests|FullyQualifiedName~GatewayAlarmMonitorAttachOrderTests|FullyQualifiedName~ApiKeyFailureLimiterTests"
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx
|
||||
```
|
||||
|
||||
Expected: all filtered tests pass; zero warnings/errors. Update tracker with per-finding commits and counts.
|
||||
|
||||
### Task 3: Batch J — Secondary Contract Guards
|
||||
|
||||
**Findings:** IPC-36, IPC-37
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs`
|
||||
- Modify: worker consumers under `src/ZB.MOM.WW.MxGateway.Worker/Bootstrap`, `Ipc`, and `MxAccess`
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs`
|
||||
- Create: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/GalaxyContractParityTests.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||
- Modify: `docs/WorkerProcessLauncher.md`
|
||||
- Modify: `docs/MxAccessWorkerInstanceDesign.md`
|
||||
- Modify: `docs/Contracts.md`
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Step 1: RED for IPC-36**
|
||||
|
||||
Change the launcher test to enumerate the six constants expected on `WorkerLaunchEnvironment` and assert each is present in `ProcessStartInfo.Environment`. Run the test and confirm compilation fails because the shared class does not exist.
|
||||
|
||||
**Step 2: GREEN for IPC-36**
|
||||
|
||||
Create a net48-safe static class containing six `public const string` names. Replace both gateway and worker literals with these constants; remove the duplicate public constants from `WorkerProcessLauncher` unless compatibility requires obsolete aliases. Add a launch-environment table with variable, source configuration key, and worker default. Refresh Write/Write2 wording.
|
||||
|
||||
**Step 3: RED for IPC-37**
|
||||
|
||||
Add `GalaxyContractParityTests` that converts both loaded descriptors with `ToProto()`, clears `SourceCodeInfo`, normalizes `Options.CsharpNamespace` and file `Name`, then compares deterministic bytes. Before the assertion, mutate a clone in a local test helper to prove the comparison reports the differing symbol/message clearly; the real descriptor assertion must pass at HEAD.
|
||||
|
||||
**Step 4: Document and verify**
|
||||
|
||||
Record the parity guard in the Contracts project comment and `docs/Contracts.md`. Run:
|
||||
|
||||
```bash
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GalaxyContractParityTests"
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx
|
||||
```
|
||||
|
||||
Then update tracker and commit IPC-36/37.
|
||||
|
||||
### Task 4: Batch K — Client Alarm-Feed Parity
|
||||
|
||||
**Findings:** CLI-47, CLI-48, CLI-49, CLI-50
|
||||
|
||||
**Files:**
|
||||
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs`
|
||||
- Test: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs`
|
||||
- Test/modify: `clients/go/cmd/mxgw-go/main.go`, `main_test.go`
|
||||
- Modify: `clients/go/mxgateway/types.go`
|
||||
- Test/modify: Java CLI and `MxGatewayCliTests.java`
|
||||
- Modify/test: `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py`, `clients/python/tests/`
|
||||
- Modify: all affected client READMEs and `docs/CrossLanguageSmokeMatrix.md`
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Step 1: RED/GREEN for CLI-47 and CLI-48**
|
||||
|
||||
In .NET, Go, and Java tests, feed `provider_status { degraded: false }` and `snapshot_status { truncated: false }`; assert exact text rows and protobuf-JSON behavior. Confirm .NET text fails as `{}` and Go lacks a stream-alarm harness. Hand-format .NET text to match the explicit rows; add the missing Go and Java coverage without changing their correct output.
|
||||
|
||||
**Step 2: RED/GREEN for CLI-49**
|
||||
|
||||
Add a Go compile-time consumer test that declares variables of `mxgateway.AlarmProviderStatus` and `mxgateway.AlarmSnapshotStatus`. Confirm red, then add aliases and update the five-arm `AlarmFeedMessage` comment.
|
||||
|
||||
**Step 3: RED/GREEN for CLI-50**
|
||||
|
||||
Add Python CLI tests proving default `stream-alarms` emits provider status, snapshot status, cached alarms, and later live messages incrementally until timeout/cancellation; add a finite `--max-messages` test. Change the default to `0` (unbounded) and print rows as consumed instead of collecting first. Keep the maximum bound for positive finite values and ensure iterator cleanup occurs in `finally`.
|
||||
|
||||
**Step 4: Verify all clients**
|
||||
|
||||
Run the authoritative commands from `docs/ClientPackaging.md`:
|
||||
|
||||
```bash
|
||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
|
||||
(cd clients/go && go test ./...)
|
||||
(cd clients/python && python -m pytest)
|
||||
(cd clients/rust && cargo test --workspace)
|
||||
(cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test)
|
||||
```
|
||||
|
||||
Revert only proven generator churn; do not revert source changes. Update tracker and commit CLI-47–50.
|
||||
|
||||
### Task 5: Batch L — Live Watch-List Refresh And Protobuf Wave
|
||||
|
||||
**Follow-ups:** complete GWC-37 application path, WRK-32 public comment
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`
|
||||
- Regenerate: Contracts and every client generated tree required by `docs/Contracts.md`
|
||||
- Modify/test: worker alarm command executor, handler, failover/subtag consumers, and Worker.Tests
|
||||
- Modify/test: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` and alarm monitor tests
|
||||
- Modify: alarm, worker protocol, configuration, and client contract documentation
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Step 1: RED — contract round trip**
|
||||
|
||||
Add a protobuf round-trip test for:
|
||||
|
||||
```proto
|
||||
message UpdateAlarmWatchListCommand {
|
||||
repeated AlarmSubtagTarget watch_list = 1;
|
||||
}
|
||||
```
|
||||
|
||||
Add a new non-conflicting `MxCommand` oneof field and `MxCommandKind`, plus an explicit `fallback_enabled` field on `SubscribeAlarmsCommand`. Confirm the test cannot compile before editing the proto.
|
||||
|
||||
**Step 2: RED — worker routing and failure preservation**
|
||||
|
||||
Add Worker.Tests covering command routing, rejection without an active/fallback-capable subscription, replacement setup failure preserving the previous standby, primary-mode replacement keeping primary active, and standby-mode replacement keeping subtag active.
|
||||
|
||||
**Step 3: GREEN — worker hot-swap**
|
||||
|
||||
Implement `IAlarmCommandHandler.UpdateWatchList`. Retain the subscription expression. Refactor `FailoverAlarmConsumer` so standby replacement performs build/subscribe/attach before swap and disposes the old standby after swap. No COM operation may leave the STA. In auto mode, build the composite with an empty standby when `fallback_enabled` is true; leave an intentionally alarm-manager-only subscription unchanged.
|
||||
|
||||
**Step 4: RED — gateway periodic update**
|
||||
|
||||
Extend `GatewayAlarmMonitorProviderModeTests`/`AlarmFailoverEndToEndTests` with these scenarios:
|
||||
|
||||
- startup discovery fails/empty, later reconcile resolves targets, exactly one update command is sent;
|
||||
- unchanged targets send no update;
|
||||
- changed targets update the sent fingerprint only after an OK reply;
|
||||
- worker rejection retains the prior fingerprint and degraded coverage reason;
|
||||
- failover edge performs an immediate refresh before publishing status.
|
||||
|
||||
**Step 5: GREEN — gateway refresh path**
|
||||
|
||||
Reuse `ReconcileIntervalSeconds`. Resolve only when mode can use subtags, compare canonical full-target fingerprints, invoke the new command on material change/recovery, and reconcile after success. Do not add a timer or option.
|
||||
|
||||
**Step 6: Complete the shared proto comments**
|
||||
|
||||
In the same proto edit:
|
||||
|
||||
- replace the IPC-36 internal environment-variable name with `MxGateway:Worker:WriteCompletionWaitMilliseconds`;
|
||||
- describe WRK-32 correlation as recency-based per handle and name the sequential timed-out-predecessor stale-callback case, not only concurrent writes.
|
||||
|
||||
**Step 7: Regenerate once and run portable checks**
|
||||
|
||||
Follow `docs/Contracts.md` exactly, then run:
|
||||
|
||||
```bash
|
||||
pwsh scripts/check-codegen.ps1
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~AlarmFailoverEndToEndTests|FullyQualifiedName~GatewayAlarmMonitorProviderModeTests|FullyQualifiedName~ClientProtoInputTests"
|
||||
```
|
||||
|
||||
Run all five client suites again because generated trees changed.
|
||||
|
||||
**Step 8: Verify worker on windev**
|
||||
|
||||
Commit the candidate, then run the repository's windev test mode at that SHA. Required evidence: x86 Worker build success and Worker.Tests with zero failures. Only then mark the watch-list/WRK-32 follow-ups Done in the tracker and commit any evidence-only tracker update.
|
||||
|
||||
### Task 6: Batch M — Windows Gateway Suite In Nightly CI
|
||||
|
||||
**Finding:** TST-35
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/ci/windev-worker-ci.ps1`
|
||||
- Modify: `.gitea/workflows/ci.yml` comments if needed
|
||||
- Modify: `docs/GatewayTesting.md`
|
||||
- Modify: `scripts/ci/README.md`
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Step 1: Add an acceptance check before behavior**
|
||||
|
||||
Document the expected `live` order and add a script-level/static assertion or dry-run seam showing gateway tests are absent before the change.
|
||||
|
||||
**Step 2: Extend live mode**
|
||||
|
||||
After Worker.Tests and full-slnx build, run:
|
||||
|
||||
```powershell
|
||||
Invoke-Native 'gateway tests on Windows' {
|
||||
dotnet test 'src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj' -c Release --no-build
|
||||
}
|
||||
```
|
||||
|
||||
Keep it before the opt-in live-MXAccess smoke, under the existing host lock. Log the actual result; do not enforce a historical count.
|
||||
|
||||
**Step 3: Verify on windev**
|
||||
|
||||
Run `live` mode at the committed SHA. Require Worker.Tests, gateway tests, full solution build, and live smoke to complete successfully. Update docs/tracker with date, SHA, counts, and duration; commit TST-35.
|
||||
|
||||
### Task 7: Batch N — Full Integration And Release
|
||||
|
||||
**Follow-up:** publish the five clients and Contracts
|
||||
|
||||
**Files:**
|
||||
- Modify after publication: `docs/ClientPackaging.md`
|
||||
- Modify: `archreview/2026-08-19/remediation/00-tracking.md`
|
||||
|
||||
**Step 1: Full local integration gate**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
pwsh scripts/check-codegen.ps1
|
||||
pwsh scripts/check-client-versions.ps1
|
||||
python3 code-reviews/regen-readme.py --check
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.NonWindows.slnx --no-build --no-restore
|
||||
```
|
||||
|
||||
Run all five client suites. Require zero unexpected working-tree changes.
|
||||
|
||||
**Step 2: Final windev gate**
|
||||
|
||||
Run `windev-worker-ci.ps1 -Mode live` at the exact candidate SHA. Record Worker.Tests, gateway tests, live smoke, and full solution build evidence.
|
||||
|
||||
**Step 3: Integrate before release**
|
||||
|
||||
Use `superpowers:finishing-a-development-branch`. Merge/push the verified branch to `main`, confirm CI green, and resolve the exact release commit SHA. Do not publish artifacts from an unmerged feature commit.
|
||||
|
||||
**Step 4: Preflight exact coordinates**
|
||||
|
||||
Confirm these are absent (HTTP 404) immediately before publishing:
|
||||
|
||||
- NuGet `ZB.MOM.WW.MxGateway.Client` 0.2.1
|
||||
- NuGet `ZB.MOM.WW.MxGateway.Contracts` 0.2.1
|
||||
- PyPI `zb-mom-ww-mxaccess-gateway-client` 0.2.1
|
||||
- Cargo `zb-mom-ww-mxgateway-client` 0.2.1
|
||||
- Maven `com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` 0.2.2
|
||||
- Git tag `clients/go/v0.2.1`
|
||||
|
||||
Abort rather than overwrite if any coordinate exists unexpectedly.
|
||||
|
||||
**Step 5: Publish from clean `main`**
|
||||
|
||||
Run the documented `scripts/pack-clients.ps1 -Publish` commands for .NET, Python, Rust, and Java with required credentials, then:
|
||||
|
||||
```powershell
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.2.1 -Push
|
||||
```
|
||||
|
||||
The Go tag must point to the exact integrated release commit.
|
||||
|
||||
**Step 6: Verify release and close tracker**
|
||||
|
||||
Query every exact registry coordinate for HTTP 200 and resolve the Go module/tag remotely. Update `docs/ClientPackaging.md` and the tracker with release SHA, coordinates, and verification date. Run the doc checks, commit the release record, and push it.
|
||||
|
||||
## Final Tasklist Exit Gate
|
||||
|
||||
- [x] All 16 open P2 findings are Done with commit/test evidence.
|
||||
- [x] WRK-34 is corrected to Done (`3679e59`).
|
||||
- [x] Live watch-list refresh passes portable and windev tests.
|
||||
- [x] WRK-32 public proto comment is regenerated through every client surface.
|
||||
- [x] TST-35 nightly Windows gateway suite is proven on windev.
|
||||
- [x] Full NonWindows gateway suite and all five client suites pass.
|
||||
- [x] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
|
||||
- [x] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
|
||||
- [x] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+57
-21
@@ -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
|
||||
@@ -249,6 +252,22 @@ monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSub
|
||||
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
|
||||
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
|
||||
|
||||
**Standby-coverage visibility:** `degraded` also covers the case where the alarm
|
||||
manager is serving normally but the subtag standby behind it could not cover
|
||||
anything — a watch-list that resolved to zero targets, or Galaxy Repository
|
||||
discovery that was enabled and failed. That condition used to be a single startup
|
||||
log line, invisible until the failover that needed the standby found it empty; it
|
||||
is now a standing `AlarmProviderStatus` reason
|
||||
(`AlarmProviderReasons.SubtagWatchListEmpty` / `SubtagDiscoveryFailed`) carried on
|
||||
the feed and rendered as the amber `Alarm Manager (no subtag standby)` badge, which
|
||||
keeps the failover label from naming the wrong live provider. The watch-list the
|
||||
worker holds is fixed for the life of the subscription, so on the failover edge the
|
||||
monitor re-resolves and reports a difference
|
||||
(`AlarmProviderReasons.SubtagWatchListStale`, with both counts) rather than
|
||||
re-sending it — re-sending would rebuild the failover consumer on the primary that
|
||||
just failed. A monitor restart re-resolves and re-sends. See
|
||||
`docs/AlarmClientDiscovery.md`.
|
||||
|
||||
**Truncated-snapshot visibility:** `GetXmlCurrentAlarms2` caps its reply at
|
||||
`MxGateway:Alarms:MaxAlarmsPerFetch` and offers no confirmed "more available"
|
||||
flag, so a reply holding exactly the cap is treated as truncated. On such a
|
||||
@@ -550,8 +569,15 @@ returns one result per requested entry — per-entry failures populate
|
||||
advised (`was_cached = true`, no subscription side-effects), or by
|
||||
taking a full `AddItem` + `Advise` + wait-for-first-OnDataChange +
|
||||
`UnAdvise` + `RemoveItem` snapshot lifecycle when no live subscription
|
||||
exists (`was_cached = false`). Per-tag timeouts surface as
|
||||
`was_successful = false` rather than throwing. The cache lives on the
|
||||
exists (`was_cached = false`). `timeout_ms` is one shared deadline for
|
||||
the whole command, not a per-tag budget: the worker adds and advises
|
||||
every uncached tag first, waits once for all of them, then tears them
|
||||
all down, so the STA is held for at most one `timeout_ms` (plus the
|
||||
add/advise and teardown COM calls, during which it pumps every 16 tags).
|
||||
Each tag that has not delivered by the deadline surfaces its own
|
||||
`was_successful = false` timeout rather than throwing. Duplicate
|
||||
addresses that MXAccess resolves to the same item handle share one
|
||||
subscription and receive the same result. The cache lives on the
|
||||
worker's `MxAccessValueCache`, populated by `MxAccessBaseEventSink` on
|
||||
every `OnDataChange` after the event clears the outbound queue.
|
||||
Required scope: `invoke:read`. `timeout_ms == 0` uses the worker's
|
||||
@@ -917,10 +943,19 @@ Timeouts:
|
||||
|
||||
Cancellation:
|
||||
|
||||
- canceling the gRPC call should stop waiting in the gateway,
|
||||
- canceling the gRPC call stops the gateway waiting **and** forwards a
|
||||
`WorkerCancel` for the abandoned correlation, the same best-effort frame the
|
||||
command timeout sends — a client deadline or a disconnect is the common
|
||||
abandonment path, and leaving the correlation on the worker's single STA queue
|
||||
makes live callers queue behind work nobody is waiting for,
|
||||
- the cancel is sent only by the caller that owned the pending-entry removal (so
|
||||
a reply that won the race is never chased) and is skipped once the client is
|
||||
stopping, where every pending command fails anyway,
|
||||
- it cannot safely abort an in-flight COM call on the STA,
|
||||
- the worker should finish the COM call and discard or log the late reply if
|
||||
the correlation was canceled,
|
||||
- caller cancellation is reported as `CallerCancelled` (mapped to gRPC
|
||||
`Cancelled`), distinct from a gateway shutdown, so metrics name the real cause,
|
||||
- hard cancellation means killing the worker process.
|
||||
|
||||
## Event Delivery And Backpressure
|
||||
@@ -1003,8 +1038,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
|
||||
|
||||
|
||||
@@ -52,31 +52,46 @@ LmxOpcUa write rights doesn't need a second account for the gw.
|
||||
|
||||
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|
||||
|---|---|---|---|---|
|
||||
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=zb,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
|
||||
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
||||
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
||||
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=zb,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
||||
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
||||
| ReadOnly | 5601 | `ou=ReadOnly,ou=groups,dc=zb,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
|
||||
| WriteOperate | 5602 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
||||
| WriteTune | 5603 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
||||
| WriteConfigure | 5604 | `ou=WriteConfigure,ou=groups,dc=zb,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
||||
| AlarmAck | 5605 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
||||
|
||||
These are the shared config's OPC-perm (`560x`) family — see
|
||||
`scadaproj/infra/glauth/README.md`'s directory-layout table, which
|
||||
partitions groups into non-overlapping gid ranges per sister app
|
||||
(`SCADA-*` 55xx, OPC-perm 560x, `Gw*` 561x, `OtOpcUa-*` 57xx) so the
|
||||
three apps coexist in one directory.
|
||||
|
||||
**A user can be in multiple groups** — `othergroups = [...]` in the
|
||||
config is a list. `admin` is the canonical example (in every role
|
||||
group below).
|
||||
config is a list. `multi-role` is the canonical example (in every
|
||||
group in the file, not just the ones below).
|
||||
|
||||
## Pre-provisioned users
|
||||
|
||||
Per-role OPC-perm testers are named `opc-*` rather than the bare role name
|
||||
(`readonly`, `writeop`, …) because a tester username must not
|
||||
case-collide with a group name — see the naming rule in
|
||||
`scadaproj/infra/glauth/README.md`.
|
||||
|
||||
| Username | Password | UID | Primary group | Other groups | Capabilities |
|
||||
|---|---|---|---|---|---|
|
||||
| `readonly` | `readonly123` | 5001 | ReadOnly | — | Browse, read |
|
||||
| `writeop` | `writeop123` | 5002 | WriteOperate | — | + plain Write |
|
||||
| `writetune` | `writetune123` | 5005 | WriteTune | — | + WriteSecured (Tune) |
|
||||
| `writeconfig` | `writeconfig123` | 5006 | WriteConfigure | — | + WriteSecured (Configure) |
|
||||
| `alarmack` | `alarmack123` | 5003 | AlarmAck | — | Alarm acknowledgment |
|
||||
| `admin` | `admin123` | 5004 | ReadOnly | WriteOperate, AlarmAck, WriteTune, WriteConfigure | All roles |
|
||||
| `opc-readonly` | `password` | 5101 | ReadOnly | — | Browse, read |
|
||||
| `opc-writeop` | `password` | 5102 | WriteOperate | — | + plain Write |
|
||||
| `opc-writetune` | `password` | 5103 | WriteTune | — | + WriteSecured (Tune) |
|
||||
| `opc-writeconfig` | `password` | 5104 | WriteConfigure | — | + WriteSecured (Configure) |
|
||||
| `opc-alarmack` | `password` | 5105 | AlarmAck | — | Alarm acknowledgment |
|
||||
| `multi-role` | `password` | 5005 | SCADA-Admins | every other group in the file, incl. all five OPC-perm groups + `GwAdmin`/`GwReader` | All roles, all apps (cross-app canonical login) |
|
||||
| `admin` | `password` | 5001 | SCADA-Admins | `GwAdmin`, `OtOpcUa-Admins` | Dashboard Administrator + ScadaBridge/OtOpcUa admin — **not** a member of any OPC-perm group, so it has no gw-side Write/WriteSecured/AlarmAck capability |
|
||||
| `serviceaccount` | `<service-account-password>` | 5999 | ReadOnly | — | LDAP search capability (for bind-then-search) |
|
||||
|
||||
For mxaccessgw dev, `admin` covers every gw-side capability test;
|
||||
`readonly` is the right "negative" case for proving Browse-OK /
|
||||
Write-denied.
|
||||
For mxaccessgw dev, `multi-role` covers every gw-side capability test
|
||||
(it is a member of all five OPC-perm groups as well as `GwAdmin`);
|
||||
`opc-readonly` is the right "negative" case for proving Browse-OK /
|
||||
Write-denied. `admin` is useful for dashboard-Administrator and
|
||||
ScadaBridge/OtOpcUa-admin testing but, per the table above, carries no
|
||||
OPC-perm group membership of its own.
|
||||
|
||||
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
|
||||
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
|
||||
@@ -121,7 +136,7 @@ principal cannot show that.
|
||||
|
||||
```
|
||||
DN: cn=admin,dc=zb,dc=local
|
||||
Password: admin123
|
||||
Password: password
|
||||
```
|
||||
|
||||
Construct the DN from the username; bind. Works on GLAuth because
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fails a PR that changes a client's source without bumping its version
|
||||
past the last thing actually published (CLI-46).
|
||||
|
||||
.DESCRIPTION
|
||||
CLI-39 (2026-07-12 cycle) added publish-time guards that refuse to
|
||||
re-publish an already-published name+version
|
||||
(scripts/pack-clients.ps1's Assert-GiteaPackageNotPublished,
|
||||
scripts/tag-go-module.ps1's ClientVersion-vs-tag check). Those guards
|
||||
only fire when someone actually runs a publish. CLI-46 (2026-08-19
|
||||
cycle) found the same hazard recurring silently in between publishes:
|
||||
library source (including a real bug fix, commit a8f86b5) shipped
|
||||
while the version constant still equalled the already-published
|
||||
number, so nothing in the tree recorded that the published artifact
|
||||
and the in-repo source had diverged. This script is the PR-time half
|
||||
of that guard: catch the drift as soon as it happens, not at the next
|
||||
publish.
|
||||
|
||||
For each client this repo ships, the script:
|
||||
1. Finds the latest "publish tag" for that client, per the tag
|
||||
naming convention its own publish tooling actually uses.
|
||||
2. If no such tag exists, says so and passes (nothing to compare
|
||||
against yet — this is expected for every client except Go; see
|
||||
below).
|
||||
3. If a tag exists, reads the version recorded in-source at that tag
|
||||
and the version recorded in-source now. If `clients/<lang>` has
|
||||
changed since the tag AND the version constant is unchanged, that
|
||||
is exactly the CLI-46 pattern: source moved, the published
|
||||
coordinate did not, and nothing says so. Fail.
|
||||
|
||||
Tag conventions, as implemented today (verified against
|
||||
scripts/pack-clients.ps1, scripts/tag-go-module.ps1, and `git tag -l`):
|
||||
- Go is the only client with a git tag at all: `tag-go-module.ps1`
|
||||
creates `clients/go/vX.Y.Z` (CLI-21/CLI-39 already keep this tag's
|
||||
version in lockstep with clients/go/mxgateway/version.go's
|
||||
ClientVersion at *tag time*; this script is what keeps it in
|
||||
lockstep *between* tags).
|
||||
- .NET, Python, Rust, and Java publish straight to a package
|
||||
registry via `pack-clients.ps1 -Publish` (NuGet/PyPI/cargo/Maven
|
||||
API pushes) with no corresponding git tag of any kind — there is
|
||||
nothing in this repo's history to diff against for those four.
|
||||
The script looks for a same-shaped tag anyway
|
||||
(`clients/<lang>/v*`) so it stays correct if that ever changes,
|
||||
but today it will report "no tags found" and pass for all four.
|
||||
|
||||
A client with no tag is not a violation of anything — it means no
|
||||
release train has ever tagged that client, so there is no "since the
|
||||
last publish" window to check. The check exists to catch drift once a
|
||||
baseline exists, not to demand one.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh scripts/check-client-versions.ps1
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
# One entry per official client. `TagGlob` is the tag naming pattern that
|
||||
# client's own publish tooling uses (see doc comment above); `VersionFile`
|
||||
# + `VersionPattern` locate the single version-of-record constant
|
||||
# docs/ClientPackaging.md's "Versioning" section names as authoritative
|
||||
# for that client; `SourceDir` is the diff scope (matches the literal
|
||||
# `clients/<lang>` scope described in the CLI-46 remediation task).
|
||||
$clients = @(
|
||||
[ordered]@{
|
||||
Name = 'dotnet'
|
||||
SourceDir = 'clients/dotnet'
|
||||
TagGlob = 'clients/dotnet/v*'
|
||||
VersionFile = 'clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj'
|
||||
VersionPattern = '<Version>([^<]+)</Version>'
|
||||
},
|
||||
[ordered]@{
|
||||
Name = 'go'
|
||||
SourceDir = 'clients/go'
|
||||
TagGlob = 'clients/go/v*'
|
||||
VersionFile = 'clients/go/mxgateway/version.go'
|
||||
VersionPattern = 'ClientVersion\s*=\s*"([^"]+)"'
|
||||
},
|
||||
[ordered]@{
|
||||
Name = 'python'
|
||||
SourceDir = 'clients/python'
|
||||
TagGlob = 'clients/python/v*'
|
||||
VersionFile = 'clients/python/src/zb_mom_ww_mxgateway/version.py'
|
||||
VersionPattern = '__version__\s*=\s*"([^"]+)"'
|
||||
},
|
||||
[ordered]@{
|
||||
Name = 'rust'
|
||||
SourceDir = 'clients/rust'
|
||||
TagGlob = 'clients/rust/v*'
|
||||
VersionFile = 'clients/rust/Cargo.toml'
|
||||
VersionPattern = 'version\s*=\s*"([^"]+)"'
|
||||
},
|
||||
[ordered]@{
|
||||
Name = 'java'
|
||||
SourceDir = 'clients/java'
|
||||
TagGlob = 'clients/java/v*'
|
||||
VersionFile = 'clients/java/build.gradle'
|
||||
VersionPattern = "version\s*=\s*'([^']+)'"
|
||||
}
|
||||
)
|
||||
|
||||
function Get-LatestTag {
|
||||
param([string]$Glob)
|
||||
|
||||
# --sort=-v:refname orders by version-aware descending semver, so the
|
||||
# first line is the newest tag matching the glob. Empty result means
|
||||
# no tag of this shape has ever been created for this client.
|
||||
$tags = git tag -l --sort='-v:refname' $Glob
|
||||
if (-not $tags) {
|
||||
return $null
|
||||
}
|
||||
return @($tags -split "`n")[0].Trim()
|
||||
}
|
||||
|
||||
function Get-VersionFromFile {
|
||||
param([string]$Path, [string]$Pattern)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
throw "Version file not found: $Path"
|
||||
}
|
||||
$content = Get-Content -Path $Path -Raw
|
||||
$match = [regex]::Match($content, $Pattern)
|
||||
if (-not $match.Success) {
|
||||
throw "Could not find a version matching /$Pattern/ in $Path"
|
||||
}
|
||||
return $match.Groups[1].Value
|
||||
}
|
||||
|
||||
function Get-VersionFromFileAtRevision {
|
||||
param([string]$Revision, [string]$Path, [string]$Pattern)
|
||||
|
||||
$content = git show "${Revision}:${Path}" 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or -not $content) {
|
||||
# The version file did not exist (or was at a different path) at
|
||||
# that tag. Treat as "unknown" rather than failing the whole
|
||||
# check — the drift comparison below only needs "does the
|
||||
# current version still equal the tag's version", and an
|
||||
# unresolvable historical version can never equal the current
|
||||
# one, so this degrades to "assume it changed" (safe: it will
|
||||
# not mask a real drift).
|
||||
return $null
|
||||
}
|
||||
$match = [regex]::Match(($content -join "`n"), $Pattern)
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
return $match.Groups[1].Value
|
||||
}
|
||||
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($client in $clients) {
|
||||
Write-Host ''
|
||||
Write-Host "== $($client.Name) ==" -ForegroundColor Cyan
|
||||
|
||||
$tag = Get-LatestTag -Glob $client.TagGlob
|
||||
if (-not $tag) {
|
||||
Write-Host " No publish tags matching '$($client.TagGlob)' — nothing to compare against yet. Pass." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host " Latest publish tag: $tag"
|
||||
|
||||
$currentVersion = Get-VersionFromFile -Path $client.VersionFile -Pattern $client.VersionPattern
|
||||
Write-Host " Current version constant ($($client.VersionFile)): $currentVersion"
|
||||
|
||||
# Any diff at all in the client's source tree since its last publish
|
||||
# tag — matches the CLI-46 remediation task's literal check.
|
||||
git diff --quiet $tag -- $client.SourceDir
|
||||
$sourceChanged = ($LASTEXITCODE -ne 0)
|
||||
|
||||
if (-not $sourceChanged) {
|
||||
Write-Host " No changes under $($client.SourceDir) since $tag. Pass." -ForegroundColor Green
|
||||
continue
|
||||
}
|
||||
|
||||
$tagVersion = Get-VersionFromFileAtRevision -Revision $tag -Path $client.VersionFile -Pattern $client.VersionPattern
|
||||
Write-Host " Source under $($client.SourceDir) changed since $tag (tag's version constant: $(if ($tagVersion) { $tagVersion } else { '<unresolvable>' }))."
|
||||
|
||||
if ($currentVersion -eq $tagVersion) {
|
||||
$failures.Add("$($client.Name): source changed since publish tag '$tag' but $($client.VersionFile) still reads '$currentVersion' (the version at '$tag'). Bump the version before another publish (CLI-46).")
|
||||
Write-Host " FAIL: version constant unchanged since the publish tag." -ForegroundColor Red
|
||||
}
|
||||
else {
|
||||
Write-Host " Version constant already bumped ('$tagVersion' -> '$currentVersion') since $tag. Pass." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Host 'Client version freshness check FAILED:' -ForegroundColor Red
|
||||
foreach ($failure in $failures) {
|
||||
Write-Host " - $failure" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host 'Client version freshness check passed.' -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -11,11 +11,15 @@ code back so a regression turns the job red.
|
||||
| File | Runs on | Role |
|
||||
|---|---|---|
|
||||
| `run-windev-ci.sh <build\|test\|live>` | Linux CI job | Writes the SSH key/known-hosts, base64-encodes a bootstrap, SSHes to windev, returns the remote exit code. |
|
||||
| `windev-worker-ci.ps1 -Sha -Mode` | windev | Locks the CI clone, checks out the SHA, runs the x86 build / `Worker.Tests` / live smoke. |
|
||||
| `windev-worker-ci.ps1 -Sha -Mode` | windev | Locks the CI clone, checks out the SHA, and runs the requested cumulative Windows tiers. |
|
||||
| `windev.known_hosts` | committed | Pinned windev host keys so `StrictHostKeyChecking=yes` has no TOFU prompt. Host keys are public; committing is intentional. |
|
||||
|
||||
Modes are cumulative: `build` = x86 Worker build; `test` = build + `Worker.Tests`;
|
||||
`live` = test + full-`slnx` build + live-MXAccess smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`).
|
||||
`live` = test + full-`slnx` build + the net10 gateway suite + live-MXAccess smoke
|
||||
(`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`). The `live` order is x86 Worker build,
|
||||
`Worker.Tests`, full solution build, gateway tests, then live smoke. One host lock covers
|
||||
checkout and every tier, so concurrent runs cannot overlap; each native command is checked
|
||||
immediately and the first nonzero exit aborts the remaining tiers and propagates to CI.
|
||||
|
||||
CI jobs (`.gitea/workflows/ci.yml`): `windows-x86` runs `test` per push/PR; `nightly-windev`
|
||||
runs `live` on the `0 6 * * *` schedule and opens a Gitea issue on failure.
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
Modes (cumulative):
|
||||
build x86 Worker build only (net48/CS0246/x86 compile guard — always fast).
|
||||
test build + Worker.Tests (x86). The per-push default.
|
||||
live test + the live-MXAccess smoke (MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1) and a full
|
||||
slnx build (surfaces Windows-only analyzer diagnostics, e.g. xUnit1030). Nightly.
|
||||
live test + full slnx build + the net10 gateway suite + the live-MXAccess smoke
|
||||
(MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1). Nightly.
|
||||
|
||||
Runs under Windows PowerShell 5.1 (`powershell.exe`), so it avoids PS7-only syntax and
|
||||
checks $LASTEXITCODE after every native command rather than relying on stderr-as-error
|
||||
@@ -112,6 +112,7 @@ try {
|
||||
|
||||
$workerProj = 'src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj'
|
||||
$workerTests = 'src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj'
|
||||
$gatewayTests = 'src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj'
|
||||
$integrationTests = 'src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj'
|
||||
|
||||
# build: always. The x86/net48 compile is the floor guard (catches CS0246 on
|
||||
@@ -127,6 +128,10 @@ try {
|
||||
# that never fire on the macOS/Linux NonWindows build.
|
||||
Invoke-Native 'full slnx build' { dotnet build src/ZB.MOM.WW.MxGateway.slnx -c Release }
|
||||
|
||||
Invoke-Native 'gateway tests on Windows' {
|
||||
dotnet test $gatewayTests -c Release --no-build
|
||||
}
|
||||
|
||||
$env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = '1'
|
||||
try {
|
||||
Invoke-Native 'live-MXAccess smoke' {
|
||||
|
||||
@@ -25,7 +25,7 @@ public static class GatewayContractInfo
|
||||
/// (<c>WorkerPipeSession.CreateDrainEventsReply</c>, the backstop that also interprets
|
||||
/// <c>max_events = 0</c>) cannot drift apart. A count cap alone is necessary but not
|
||||
/// sufficient: the worker additionally caps the reply by serialized bytes against the
|
||||
/// negotiated frame maximum (WRK-21), so a reply may carry fewer events than this ceiling
|
||||
/// negotiated frame maximum, so a reply may carry fewer events than this ceiling
|
||||
/// and fewer than are queued. Callers drain iteratively until an empty reply.
|
||||
/// This is a documented behavioral bound, not wire schema — it is deliberately a C#
|
||||
/// constant and not a <c>.proto</c> field.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,7 @@ message MxCommand {
|
||||
WriteSecuredBulkCommand write_secured_bulk = 41;
|
||||
WriteSecured2BulkCommand write_secured2_bulk = 42;
|
||||
ReadBulkCommand read_bulk = 43;
|
||||
UpdateAlarmWatchListCommand update_alarm_watch_list = 44;
|
||||
PingCommand ping = 100;
|
||||
GetSessionStateCommand get_session_state = 101;
|
||||
GetWorkerInfoCommand get_worker_info = 102;
|
||||
@@ -174,6 +175,7 @@ enum MxCommandKind {
|
||||
MX_COMMAND_KIND_WRITE_SECURED_BULK = 32;
|
||||
MX_COMMAND_KIND_WRITE_SECURED2_BULK = 33;
|
||||
MX_COMMAND_KIND_READ_BULK = 34;
|
||||
MX_COMMAND_KIND_UPDATE_ALARM_WATCH_LIST = 35;
|
||||
MX_COMMAND_KIND_PING = 100;
|
||||
MX_COMMAND_KIND_GET_SESSION_STATE = 101;
|
||||
MX_COMMAND_KIND_GET_WORKER_INFO = 102;
|
||||
@@ -344,8 +346,8 @@ enum AlarmProviderMode {
|
||||
// Subscription expression follows the canonical
|
||||
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
||||
// worker spins up a wnwrapConsumer-backed subscription on its STA on
|
||||
// first call; subsequent calls are an error (use UnsubscribeAlarms then
|
||||
// SubscribeAlarms to reconfigure).
|
||||
// first call; subsequent calls are an error. Use UpdateAlarmWatchList to
|
||||
// replace the standby targets without restarting the primary subscription.
|
||||
message SubscribeAlarmsCommand {
|
||||
string subscription_expression = 1;
|
||||
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||
@@ -354,6 +356,17 @@ message SubscribeAlarmsCommand {
|
||||
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
||||
repeated AlarmSubtagTarget watch_list = 3;
|
||||
AlarmFailoverConfig failover = 4;
|
||||
// True when auto mode should retain subtag-fallback capability even if the
|
||||
// gateway's initial discovery produced an empty watch list. False leaves an
|
||||
// empty list as an intentional alarm-manager-only subscription.
|
||||
bool fallback_enabled = 5;
|
||||
}
|
||||
|
||||
// Replace the live alarm subscription's subtag standby targets. The worker
|
||||
// constructs and attaches the replacement before swapping it into service, so
|
||||
// a setup failure leaves the existing standby and active provider unchanged.
|
||||
message UpdateAlarmWatchListCommand {
|
||||
repeated AlarmSubtagTarget watch_list = 1;
|
||||
}
|
||||
|
||||
// Tear down the worker's alarm consumer. No-op if no subscription is
|
||||
@@ -378,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;
|
||||
@@ -390,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
|
||||
@@ -401,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
|
||||
@@ -418,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 {
|
||||
@@ -537,11 +559,14 @@ message MxCommandReply {
|
||||
MxValue return_value = 6;
|
||||
// Correlated per-item outcome rows. For WRITE / WRITE2 / WRITE_SECURED /
|
||||
// WRITE_SECURED2 replies the worker holds the reply for a bounded window
|
||||
// (default 1.5 s, MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for
|
||||
// (default 1.5 s, MxGateway:Worker:WriteCompletionWaitMilliseconds) waiting for
|
||||
// the matching MXAccess OnWriteComplete callback and copies its status rows
|
||||
// here, so statuses[0] carries the real MXAccess commit outcome (success OR
|
||||
// failure) while protocol_status/hresult still describe command acceptance
|
||||
// only. Empty statuses on a write reply means the completion did not arrive
|
||||
// only. Callback correlation is recency-based per item handle: after the
|
||||
// bounded wait, a sequential write's callback supersedes a timed-out
|
||||
// predecessor, and a later stale callback for that predecessor is ignored.
|
||||
// Empty statuses on a write reply means the completion did not arrive
|
||||
// within the window — the write is unconfirmed, not failed. Correlation is
|
||||
// best-effort per (server_handle, item_handle): MXAccess's callback carries
|
||||
// no transaction id, so concurrent writes to the same item within the
|
||||
@@ -709,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 {
|
||||
@@ -971,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 {
|
||||
@@ -981,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;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Contracts;
|
||||
|
||||
/// <summary>Names of environment variables shared by the gateway worker launcher and worker process.</summary>
|
||||
public static class WorkerLaunchEnvironment
|
||||
{
|
||||
/// <summary>Bootstrap nonce used to authenticate the worker handshake.</summary>
|
||||
public const string WorkerNonce = "MXGATEWAY_WORKER_NONCE";
|
||||
|
||||
/// <summary>Per-attempt named-pipe connection timeout in milliseconds.</summary>
|
||||
public const string PipeConnectAttemptTimeoutMilliseconds =
|
||||
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
|
||||
|
||||
/// <summary>Bounded wait for a Write/Write2/WriteSecured/WriteSecured2 completion callback in milliseconds.</summary>
|
||||
public const string WriteCompletionWaitMilliseconds =
|
||||
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
||||
|
||||
/// <summary>Capacity of the worker's outbound MXAccess event queue.</summary>
|
||||
public const string EventQueueCapacity = "MXGATEWAY_EVENT_QUEUE_CAPACITY";
|
||||
|
||||
/// <summary>Worker alarm polling cadence in milliseconds.</summary>
|
||||
public const string AlarmPollIntervalMilliseconds = "MXGATEWAY_ALARM_POLL_INTERVAL_MS";
|
||||
|
||||
/// <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";
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Contracts</PackageId>
|
||||
<Version>0.2.0</Version>
|
||||
<Version>0.2.1</Version>
|
||||
<Authors>Joseph Doherty</Authors>
|
||||
<Company>ZB MOM WW</Company>
|
||||
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||
@@ -37,7 +37,9 @@
|
||||
the wire-identical Galaxy types from the ZB.MOM.WW.GalaxyRepository package. This proto is
|
||||
still the generation source of truth for the language clients (Go/Rust/Java/Python compile it
|
||||
by path; the .NET client consumes Contracts.Proto.Galaxy.* via this project reference) and for
|
||||
clients/proto/proto-inputs.json. Removing it breaks all five clients — do not delete. -->
|
||||
clients/proto/proto-inputs.json. GalaxyContractParityTests normalizes its descriptor against
|
||||
the descriptor served by the package and fails on wire drift. Removing it breaks all five
|
||||
clients — do not delete. -->
|
||||
<Protobuf Include="Protos\galaxy_repository.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class DashboardLdapLiveTests
|
||||
private const string SharedDirectoryPassword = "password";
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen
|
||||
/// Dashboard visibility tags used by the ACL scenarios below. They are operator-chosen
|
||||
/// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag
|
||||
/// list — nothing in the shared directory carries them.
|
||||
/// </summary>
|
||||
@@ -168,7 +168,7 @@ public sealed class DashboardLdapLiveTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
|
||||
/// Verifies the tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
|
||||
/// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>,
|
||||
/// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a
|
||||
/// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one.
|
||||
@@ -275,7 +275,7 @@ public sealed class DashboardLdapLiveTests
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
|
||||
/// The tagged fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
|
||||
/// name groups that already exist in the shared directory — the tag layer is config-only.
|
||||
/// </summary>
|
||||
private static DashboardOptions TaggedDashboardOptions() => new()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
@@ -19,14 +18,18 @@ public sealed class EmptyAlarmWatchListResolver : IAlarmWatchListResolver
|
||||
/// <summary>Shared no-op instance.</summary>
|
||||
public static readonly EmptyAlarmWatchListResolver Instance = new();
|
||||
|
||||
private static readonly IReadOnlyList<AlarmSubtagTarget> Empty = [];
|
||||
private static readonly AlarmWatchListResolution Empty = new(
|
||||
[],
|
||||
DiscoveryAttempted: false,
|
||||
DiscoveryFailed: false,
|
||||
DiscoveryFailureMessage: null);
|
||||
|
||||
private EmptyAlarmWatchListResolver()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
||||
public Task<AlarmWatchListResolution> ResolveAsync(
|
||||
AlarmsOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user