Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd0e88e74c | |||
| 0a9715d819 | |||
| 01033d7aaf | |||
| dc53f04b81 | |||
| 917694c33d | |||
| 3e504ca9c4 | |||
| 02baf0c27b | |||
| bfcf82975c | |||
| f78781d9ef | |||
| e2352d1666 | |||
| 347d59fc62 | |||
| 404c06b2fa | |||
| a8f86b5336 | |||
| 50322bacb3 | |||
| 9e6f66dd8f | |||
| 30c92e8e59 | |||
| d4302c6ac4 | |||
| c46e5bbd15 | |||
| 5fe74db971 | |||
| 2c03e0a684 | |||
| 8624e21372 | |||
| 84dbf20a43 | |||
| 8769ee9765 | |||
| 0152180929 | |||
| 75c71adf45 | |||
| 53f69cde37 | |||
| 91d8715c74 | |||
| 794c44246a | |||
| b0e65d4f31 | |||
| b948e6975e | |||
| 45c530da6e | |||
| c867aca36b | |||
| 436ef69f07 | |||
| 2b468bd8fc | |||
| 431a096cab | |||
| b0e2b8ba74 | |||
| 66fe063410 | |||
| 8de23086d0 | |||
| a76ecdd59c | |||
| fc23a65cca | |||
| aec95b78c9 | |||
| f9229ee44d | |||
| 5dbe93d13e | |||
| 129e47e541 | |||
| 1d6858939d | |||
| de67b45d04 | |||
| 3d991d2160 | |||
| 05667169eb | |||
| 9760497d66 | |||
| 5b153dac74 | |||
| 41e86481e2 | |||
| a346d514dd | |||
| a2d3f66b8b | |||
| 93d84019b9 | |||
| 6d26ed094c | |||
| 4201da63d2 | |||
| 9c780f8164 | |||
| 440e7cf03d | |||
| ae605d2368 | |||
| 9b2abef4e1 | |||
| a55956ffa5 | |||
| aba22358f5 | |||
| b604fed72b | |||
| 6060d21995 | |||
| 1c2f3a62c1 | |||
| 0646c73e48 | |||
| eacdd2d453 | |||
| 8c312c717c |
+37
-12
@@ -60,7 +60,19 @@ jobs:
|
||||
dotnet tool install --global PowerShell
|
||||
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
|
||||
|
||||
# IPC-01 / IPC-19 / IPC-20: descriptor set + Contracts/Generated must match the current protos.
|
||||
# IPC-25 Check 4 regenerates the Go and Python client bindings and diffs them, so the pinned
|
||||
# generators must be present. protoc 34.1 is already installed above; Go and Python are set up
|
||||
# above. Pin protoc-gen-go / protoc-gen-go-grpc to match the committed header stamps and grpcio
|
||||
# -tools to match the committed _pb2 stamp, or Check 4 false-fails (or masks drift) under churn.
|
||||
- name: Install pinned client codegen generators (Check 4)
|
||||
run: |
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
python -m pip install 'grpcio-tools==1.80.0'
|
||||
|
||||
# IPC-01 / IPC-19 / IPC-20 / IPC-25: descriptor set + Contracts/Generated + Go/Python bindings
|
||||
# must match the current protos.
|
||||
- name: Codegen / descriptor freshness
|
||||
shell: pwsh
|
||||
run: ./scripts/check-codegen.ps1
|
||||
@@ -71,6 +83,12 @@ jobs:
|
||||
- name: .NET client
|
||||
run: dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx -c Release
|
||||
|
||||
# The .NET client was previously only compiled here, so its test project — including
|
||||
# MxGatewayClientWireTests, which drives the client against a real loopback gRPC
|
||||
# server (TST-24) — never ran in CI. Every other client job already runs its tests.
|
||||
- name: .NET client tests
|
||||
run: dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj -c Release --no-build
|
||||
|
||||
- name: Go client
|
||||
working-directory: clients/go
|
||||
run: |
|
||||
@@ -93,10 +111,12 @@ jobs:
|
||||
python -m pytest
|
||||
|
||||
java:
|
||||
# Java client runs on a JDK-17 Linux runner (the macOS dev box has no JRE). The protobuf gradle
|
||||
# plugin rewrites MxaccessGateway.java with spurious protobuf-runtime-version churn on every
|
||||
# build; when no .proto changed, revert that one file so checkGeneratedClean / a dirty tree does
|
||||
# not fail the build (repo memory project_java_generated_churn).
|
||||
# Java client runs on a JDK-17 Linux runner (the macOS dev box has no JRE). The grpc/protobuf
|
||||
# toolchain is fully pinned (clients/java/build.gradle: grpcVersion 1.76.0 / protobufVersion
|
||||
# 4.33.1), so a regeneration is byte-identical to the committed aggregates modulo real .proto
|
||||
# changes — `Verify generated tree is clean` (git diff) is the true drift gate (IPC-24). The
|
||||
# single-file Java aggregates are where message-level proto drift lands, so this job now catches
|
||||
# a .proto edited without regenerating and committing the Java client.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -115,13 +135,10 @@ jobs:
|
||||
- name: Gradle test
|
||||
working-directory: clients/java
|
||||
run: gradle test
|
||||
- name: Revert spurious protobuf-version churn (no .proto changed)
|
||||
# Both generated aggregates can pick up protobuf-runtime-version churn on regen; revert
|
||||
# both so verify-clean still catches a real, uncommitted proto/codegen change elsewhere.
|
||||
run: |
|
||||
git checkout -- clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java || true
|
||||
git checkout -- clients/java/src/main/generated/main/java/mxaccess_worker/v1/MxaccessWorker.java || true
|
||||
- name: Verify generated tree is clean
|
||||
# IPC-24: the pinned grpc/protobuf toolchain regenerates byte-identical output, so this
|
||||
# git-diff gate now catches message-level proto drift in the single-file Java aggregates
|
||||
# (the old unconditional churn-revert step masked exactly that class and was deleted).
|
||||
run: git diff --exit-code -- clients/java/src/main/generated
|
||||
|
||||
windows-x86:
|
||||
@@ -154,6 +171,14 @@ jobs:
|
||||
# visible even though nobody watches the Actions page.
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# `github.server_url` is the URL Gitea hands the runner — the docker-network-internal
|
||||
# `http://gitea:3000`. That is the right base for the issue-creation API call below (the job
|
||||
# container resolves `gitea` only on that network, and has no LAN egress to the public origin),
|
||||
# but it is useless as a link a human clicks out of the issue. So browser-facing URLs in the
|
||||
# issue body use the public origin instead. TST-25 acceptance Check 6 caught this: every
|
||||
# nightly issue since #126 carried an unreachable `http://gitea:3000/...` run link.
|
||||
PUBLIC_SERVER_URL: https://gitea.dohertylan.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Full Worker.Tests + live-MXAccess smoke on windev
|
||||
@@ -173,7 +198,7 @@ jobs:
|
||||
-H "Authorization: token ${{ github.token }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/issues" \
|
||||
-d "{\"title\":\"nightly-windev failed on ${{ github.sha }}\",\"body\":\"The scheduled windev Worker + live-MXAccess run failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} . A red nightly may mean the Windows tier is down rather than the change — see docs/GatewayTesting.md (Continuous Integration).\"}"
|
||||
-d "{\"title\":\"nightly-windev failed on ${{ github.sha }}\",\"body\":\"The scheduled windev Worker + live-MXAccess run failed: ${PUBLIC_SERVER_URL}/${{ github.repository }}/actions/runs/${{ github.run_id }} . A red nightly may mean the Windows tier is down rather than the change — see docs/GatewayTesting.md (Continuous Integration).\"}"
|
||||
|
||||
# NOTE: there is intentionally no native `windows` runner job. act_runner v0.6.1 host-mode on
|
||||
# Windows is broken and Windows containers are impractical for the net48/x86/MXAccess Worker, so the
|
||||
|
||||
@@ -10,7 +10,7 @@ The architecture is a two-process design — read `gateway.md` before making str
|
||||
|
||||
- **Gateway** (`src/ZB.MOM.WW.MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. The Galaxy-browse implementation comes from the shared **`ZB.MOM.WW.GalaxyRepository`** package (`AddZbGalaxyRepository`/`MapZbGalaxyRepository`), not inline code; mxaccessgw adds `GatewayBrowseScopeProvider` (per-key browse-subtree scoping) and a host-side dashboard summary projector. See `A2-galaxyrepository-adoption-handoff.md`. **Never instantiates MXAccess COM directly.**
|
||||
- **Worker** (`src/ZB.MOM.WW.MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf.
|
||||
- **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxaccess-gateway-{gatewayPid}-{sessionId}`) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack.
|
||||
- **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxgw-{gatewayPid}-{sessionUid}` — kept short so the macOS/Linux test matrix's Unix-domain-socket path fits the 104-byte macOS `sun_path` limit) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack.
|
||||
- **Contracts** (`src/ZB.MOM.WW.MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`. Note `galaxy_repository.proto` is intentionally kept here as the generation source for the language clients even though the gateway server consumes the wire-identical Galaxy types from the `ZB.MOM.WW.GalaxyRepository` package — it is not dead code; deleting it breaks all five clients.
|
||||
|
||||
The worker must do all MXAccess COM calls on its dedicated STA thread, and the STA loop must pump Windows messages (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) so MXAccess events deliver. A plain blocking queue on an STA is not enough.
|
||||
@@ -124,7 +124,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
||||
|
||||
When source code changes, build and test the affected component before reporting work done. If the change crosses component boundaries, build each affected component — don't rely on a single top-level build:
|
||||
|
||||
**Run targeted tests per task, never the full suite each time.** When executing a plan task-by-task, run only the tests that exercise the code that task touched (`dotnet test --filter "FullyQualifiedName~<TestClass>"`, or the per-task test named in the plan). The full gateway suite is slow — run it at most once per phase (after a related batch of tasks lands), not after every task. This is a speed guideline, not a correctness one: the suite exits cleanly (verified on macOS and the Windows dev box — 0 surviving `testhost`/worker processes after a full run), so filtered runs are about turnaround, not about avoiding a process leak.
|
||||
**Run targeted tests per task, never the full suite each time.** When executing a plan task-by-task, run only the tests that exercise the code that task touched (`dotnet test --filter "FullyQualifiedName~<TestClass>"`, or the per-task test named in the plan). The full gateway suite is slow — run it at most once per phase (after a related batch of tasks lands), not after every task. This is a speed guideline, not a correctness one: the suite exits cleanly on both macOS and windev (0 surviving `testhost`/worker processes after a full run), so filtered runs are about turnaround. The long-standing windev full-suite wedge — every test reported, then `testhost` never exiting — was a zero-buffer named pipe blocking one test's write forever; it is fixed and the `--blame-hang` workaround is no longer needed. See "Running the Gateway Suite on windev" in `docs/GatewayTesting.md`, which also documents the load-sensitivity caveat that still applies there.
|
||||
|
||||
| Changed area | Required verification |
|
||||
|---|---|
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -61,7 +61,7 @@ Coordinate with (do not block on) open GWC-21: if `EventChannelFullModeTimeout`
|
||||
|
||||
The proto comment currently states "`oldest_available_sequence` itself IS still retained", which becomes false in the empty-ring case — per the docs-with-source rule, amend the field comment in the same commit to define the empty-ring value ("when nothing is retained, this is the next sequence that can be delivered — `highest observed + 1` — and the `oldest − 1` resume formula remains valid; the interval evicted is unchanged"). This is a comment-only proto change (no descriptor delta), but the repo's codegen rules still apply — see the steps.
|
||||
|
||||
**Implementation.**
|
||||
**Implementation.** (Code + `docs/Sessions.md` landed 2026-08-07 on `fix/gwc-25-replaygap-trio`; the deferred proto-comment amendment below **landed 2026-08-07** with the IPC-23 codegen wave on `fix/ipc-24-25-codegen` — GWC-25 is fully resolved.)
|
||||
- `Sessions/SessionEventDistributor.cs:463-467`: replace `oldestAvailableSequence = 0;` with `oldestAvailableSequence = gap ? _highestSequenceSeen + 1 : 0;` plus a comment explaining the `oldest − 1` client formula this must keep valid (cite this finding).
|
||||
- `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`ReplayGap.oldest_available_sequence`, ~line 759): append the empty-ring sentence above. Then regenerate per repo rules: delete `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs`, `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`, and **commit `Generated/`** (net48 worker builds break otherwise). Sync the vendored client copies of the proto byte-identical (`clients/*/`); a comment-only edit changes no descriptor, so: Python `*_pb2*` output is unchanged (comments are not embedded — regenerate with the pinned grpcio-tools only if the files actually differ), Go/C#/Rust generated doc comments will churn — regenerate those per each client README, and revert spurious Java aggregate-file churn if no message-level delta appears (per the established Java convention).
|
||||
- `docs/Sessions.md` (~lines 228-234, ReplayGap section): document the empty-ring sentinel value and that `after_worker_sequence = oldest_available_sequence − 1` is the universal resume formula in both the retained and fully-evicted cases.
|
||||
|
||||
@@ -12,16 +12,16 @@ All `path:line` citations were re-verified against the working tree at `4f5371f`
|
||||
|
||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||
|----|-----|------|-----|-----|--------|-------|
|
||||
| IPC-23 | Medium | P0 | S¹ | WRK-21 | In progress — mechanics landed with WRK-21; proto-comment/doc wave pending | DrainEvents bound is count-based only; byte-heavy queue still builds a session-killing reply frame (contract requirements here; fix mechanics in WRK-21) |
|
||||
| IPC-24 | Medium | P0 | S | — | Not started | CI's unconditional Java churn-revert masks real generated-code drift for message-level proto changes |
|
||||
| IPC-25 | Medium | P0 | M | — | Not started | Committed Go/Python worker bindings are stale at HEAD; no guard covers them |
|
||||
| IPC-23 | Medium | P0 | S¹ | WRK-21 | Done | DrainEvents bound is count-based only; byte-heavy queue still builds a session-killing reply frame (contract requirements here; fix mechanics in WRK-21) |
|
||||
| IPC-24 | Medium | P0 | S | — | Done | CI's unconditional Java churn-revert masks real generated-code drift for message-level proto changes |
|
||||
| IPC-25 | Medium | P0 | M | — | Done | Committed Go/Python worker bindings are stale at HEAD; no guard covers them |
|
||||
| IPC-26 | Low | P2 | S¹ | WRK-22 | Done (mechanics landed in WRK-22) | Cancelled write leaves a ghost frame that is still written (contract requirement here; fix mechanics in WRK-22) |
|
||||
| IPC-27 | Low | P2 | S | — | Not started | Descriptor freshness test blind to enums, enum values, services/methods, and the Galaxy contract |
|
||||
| IPC-27 | Low | P2 | S | — | Done | Descriptor freshness test blind to enums, enum values, services/methods, and the Galaxy contract |
|
||||
| IPC-28 | Low | — | S | — | Done | `docs/Grpc.md` omits the `CommandTooLarge` → `ResourceExhausted` mapping |
|
||||
| IPC-29 | Low | — | S | — | Done (discharged by WRK-26) | Worker writer priority scheduling and write-time sequence stamping undocumented in the frame-protocol doc |
|
||||
| IPC-30 | Low | P0 | M | WRK-21 (same file/batch) | Done | Oversized worker→gateway event frame is session-fatal — make the death deliberate, structured, and diagnosable |
|
||||
| IPC-31 | Info | — | — | — | N/A | Gateway stamps sequence at creation, worker at write — accepted divergence; sequence is documented diagnostic-only (`gateway.md:328-330`); revisit only if sequence ever becomes load-bearing |
|
||||
| IPC-32 | Info | — | S | IPC-25 | Not started | `check-codegen.ps1` check labels miscounted (folded into the IPC-25 script edit) |
|
||||
| IPC-32 | Info | — | S | IPC-25 | Done | `check-codegen.ps1` check labels miscounted (folded into the IPC-25 script edit) |
|
||||
|
||||
¹ Effort for the work owned by *this* plan (proto comments + docs + acceptance criteria). The code mechanics are M and are tracked under WRK-21 / WRK-22 in the worker plan.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Repo rules that bind every entry: docs change in the same commit as the source (
|
||||
| SEC-33 | Low | P1 | M | — (co-locate SEC-23) | Done | Any-platform path-rooting acceptance re-opens SEC-01 on Unix; Galaxy `SnapshotCachePath` unvalidated |
|
||||
| SEC-34 | Low | P2 | S | — | Done | Verification cache: expiry outlives TTL; `Invalidate` races in-flight repopulation |
|
||||
| SEC-35 | Info | — | S | — | N/A (doc-only note discharged 2026-08-07) | Production hard-stops key on the exact `Production` environment name |
|
||||
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Not started | Committed dev LDAP service-account password: remove from repo and rotate |
|
||||
| SEC-36 | Low | P1 | M | cross-repo (`scadaproj/infra/glauth`) | Done | Committed dev LDAP service-account password: remove from repo and rotate |
|
||||
|
||||
---
|
||||
|
||||
@@ -218,3 +218,9 @@ dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --fil
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayOptionsValidator"
|
||||
```
|
||||
(asserts the blank-password validation still fires with the updated message). Manual: with user-secrets set on the dev box, `dotnet run --project src/ZB.MOM.WW.MxGateway.Server/...` and a dashboard `/login` as `multi-role` succeeds against the rotated GLAuth; the deployed-host login re-check from step 1 counts as the production verification. Live-LDAP integration tests (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) only where the GLAuth instance is reachable; otherwise document skipped per the testing matrix.
|
||||
|
||||
**Outcome (2026-08-07 — Done, repo-side; live rotation operator-pending).** Landed on `fix/sec-36-ldap-secret`. **The design's baseline had already shifted:** at HEAD `appsettings.json` no longer commits the literal — it ships `"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}"`, a fail-closed encrypted-store reference (documented `GatewayConfiguration.md:252`, tested by `PreHostSecretExpansionTests`) introduced by the Secrets-store adoption after this remediation was written. **Deviation from Implementation step 2:** the `${secret:}` reference was **kept, not deleted** — deleting it regresses the shipped/documented/tested store channel and the committed-plaintext finding is already resolved for `appsettings.json`. The load-bearing residual — the literal value still present in `glauth.md`'s samples (`:33,65,103,136,245`), `docs/GatewayTesting.md`, and the historical `archreview/*` SEC-06 evidence — was scrubbed to `<service-account-password>` placeholders, each with a pointer to the source of truth `scadaproj/infra/glauth/` and a rotation-required note. Steps 3–6 implemented as designed: `<UserSecretsId>mxaccessgw-server</UserSecretsId>` added (step 3); the `ValidateLdap` blank-password message now names both channels — dev `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` and deployed `MxGateway__Ldap__ServiceAccountPassword` — plus a note on the `${secret:}` store default (step 4), asserted by the extended `Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank`; docs updated same commit (step 5); `git grep -i` for the old value is empty across tracked files (step 6). The cross-repo **step 1 (rotate GLAuth on `10.100.0.35`, pre-stage the NSSM env var on `10.100.0.48` and on `wonder-app-vd03` only if `Ldap.Enabled`, verify dashboard login)** is the operator's to execute, captured in the new runbook `docs/runbooks/SEC-36-ldap-credential-rotation.md`. Verification (macOS): `dotnet build …Server` 0 warnings/0 errors; `dotnet test --filter ~GatewayOptionsValidator` green.
|
||||
|
||||
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The cross-repo step 1 left open above was executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`. A new service-account password was generated, the `serviceaccount` `passsha256` in `scadaproj/infra/glauth/config.toml` replaced, and the shared GLAuth recreated on `10.100.0.35` from its actual compose directory — the **load-bearing** half of the finding is now discharged: the value disclosed by this repo's git history (live in the directory since 2026-06-04; not reproduced here) no longer binds `dc=zb,dc=local`. The new value exists only in the three channels the design named — the GLAuth `passsha256` (committed in `scadaproj`, commit `aada53b`), the NSSM service environment on `10.100.0.48`, and each dev box's user-secrets — and in no file of this repo. The retired plaintext was additionally scrubbed from the `scadaproj` glauth comments (`config.toml`, `docker-compose.yml`, `README.md`) and from the docker host's live `docker-compose.yml`; the host's `*.bak-sec36` rollback copies deliberately retain it. **Three runbook facts were wrong and are corrected in a dated block at its top.** (1) Its step 3 said `cd ~/Desktop/scadaproj/infra/glauth` on the docker host; no such path exists there — the stack runs from `/home/dohertj2/zb-glauth` (container `zb-shared-glauth`), fed by the `scp` deploy documented in `scadaproj/infra/glauth/README.md`. (2) `wonder-app-vd03` is **out of scope on documentary evidence**, not merely unchecked: its gateway binds the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`, a different directory that never held this credential (the host is also unreachable from the dev network); no env var was staged there. (3) Its "3-fail / 10-minute per-IP lockout" caution is **inert for this instance** — `config.toml:14` sets `LimitFailedBinds = false`. **One Done criterion is met with a caveat:** the new value **is** staged on windev (`10.100.0.48`, 10th `AppEnvironmentExtra` entry on the `MxAccessGw` NSSM service), but the runbook's primary check — dashboard `/login` as `multi-role` — **could not run**, because windev's gateway is crash-looping on an unrelated pre-existing fault: the deployed Server binary (2026-06-25) predates the 2026-07-15 auth-DB migration, so it opens a schema-version-3 database it supports only at version 2 and aborts at startup (~10k Hosting-failed events/day since at least 08-06). That is a stale-deployment problem, filed as a next-cycle candidate finding, not a rotation defect. **Verified instead by the equivalent primitive:** a direct `ldapsearch` bind as `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and returned the `multi-role` entry — the same search bind the dashboard performs. Also surfaced and filed for next cycle: `DashboardLdapLiveTests` fixture drift leaves the suite with **no positive-proof coverage** of the service-account bind, so it could not have substituted for the dashboard check either. Tracking: both registers' SEC-36 rows, the pending-operator-actions list in `90-candidate-findings-next-cycle.md`, and the `00-tracking.md` progress log.
|
||||
|
||||
**Addendum (2026-08-07, later the same day — the caveat is closed).** windev was repaired under NEXT-07 (fresh publish of `origin/main` `a346d51`), and the deferred dashboard check then ran on that host: with `Dashboard:DisableLogin=false` supplied as a process-env-only override on a foreground run, `GET /login` returned 200 with an antiforgery token, `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard` cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to `/login?ReturnUrl=%2F`. The rotated credential is therefore proven through the real `DashboardAuthenticator` search-bind path on the deployed host, not only by the `ldapsearch` primitive. As deployed windev keeps `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression proof is the realigned `DashboardLdapLiveTests` (NEXT-06, commit `de67b45`), 5/5 green against the shared GLAuth. `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3 carries the same record.
|
||||
|
||||
@@ -20,7 +20,7 @@ Operating constraints carried from prior work:
|
||||
| CLI-36 | Medium | P0 | S | — | Done | Go CLI `stream-events` silently destroys the ReplayGap signal |
|
||||
| CLI-37 | Medium | P1 | M | CLI-38 | Done | Status-array validation must branch on `category` per the proto contract (4-vs-1 divergence) |
|
||||
| CLI-38 | Medium | P1 | S | — | Done | Align .NET/Go/Java on `hresult < 0` — lands prior CLI-08 and cures the design-doc drift |
|
||||
| CLI-39 | Medium | P1 | S | CLI-35..38, CLI-45 | Not started | Bump client versions off the already-published 0.1.2 before the next publish; add registry-collision guard |
|
||||
| CLI-39 | Medium | P1 | S | CLI-35..38, CLI-45 | Done | Bump client versions off the already-published 0.1.2 before the next publish; add registry-collision guard |
|
||||
| CLI-40 | Low | — | M | — | Done | Port the exact-secret credential scrub to Rust/Java/.NET |
|
||||
| CLI-41 | Low | — | M | — | Done | Uniform malformed-reply contract for AuthenticateUser/ArchestrAUserToId/AddBufferedItem |
|
||||
| CLI-42 | Low | P1 | S | — | Done | Document the vendored Rust proto layout (CLI-02's missing doc half) |
|
||||
|
||||
@@ -15,7 +15,7 @@ Prior-cycle open findings (TST-05..24 where still open) are tracked in the prior
|
||||
| TST-27 | Medium | P1 (doc batch) | S | — | Done | `ShowTagValues` config row still says "Reserved" after SEC-25 made the flag live |
|
||||
| TST-28 | Low | P2 | S | relates IPC-02 | Done | Gateway-side `max_frame_bytes` handshake field untested in the CI-run suite |
|
||||
| TST-29 | Low | P2 | S | — | Done | Retire `oldtasks.md` after folding the Phase-5 governance record into DesignDecisions.md; delete root docs-review artifacts |
|
||||
| TST-30 | Low | P2 | M | — | Not started | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
|
||||
| TST-30 | Low | P2 | M | — | Done | Single shared Gitea runner is a CI throughput/availability bottleneck (cross-repo contention, no run cancel/delete) |
|
||||
|
||||
---
|
||||
|
||||
@@ -167,11 +167,15 @@ Independent of the runner count, document the **no-cancel** reality (Gitea 1.26
|
||||
|
||||
**Verification.** Push two branches back-to-back and confirm their runs execute concurrently (not serially) once a second runner exists; `GET /repos/dohertj2/mxaccessgw/actions/runners` (or the instance runner list) shows ≥2 runners online; `docs/GatewayTesting.md` describes the shared-runner/no-cancel reality and the bypass. Re-run the TST-25 acceptance push and confirm queue depth is materially lower under a concurrent `lmxopcua` run.
|
||||
|
||||
**Outcome (2026-08-07 — Done, doc half; runner registration operator-pending).** Landed on `fix/tst-30-runner-docs`. Implementation step 2 shipped: `docs/GatewayTesting.md`'s Continuous Integration section gained a "Runner capacity is shared and finite" subsection stating the `maxParallel=1` co-located runner is shared with `dohertj2/lmxopcua` at the instance level (not repo-scoped), the ~20–30 minute queue latency observed under cross-repo contention, and the Gitea 1.26 no-cancel/no-delete API reality; the existing "windev tier down" degraded-mode paragraph now also covers "runner contended" as a reason to use the bypass, generalized per this finding's design note. New operator runbook `docs/runbooks/TST-30-second-ci-runner.md` carries **step 1** (register a second `act_runner` on `10.100.0.35`, option (a) recommended, same `container.network: traefik` config; option (b) dedicated labelled runner as an escalation; option (c) windev-hosted runner rejected) with the verification checklist (concurrent back-to-back pushes, `GET /repos/dohertj2/mxaccessgw/actions/runners` ≥ 2) and a note that the no-cancel reality persists regardless of runner count. **Step 3 (optional workflow-level `concurrency` group)** is documented in the runbook as unverified — explicitly framed as "verify this Gitea deployment honors it before relying on it" — and left unimplemented in `ci.yml`, since it is a `ci.yml` change out of scope for this doc-only pass. **The actual runner registration (step 1) is infrastructure work outside this repo's tree and remains the operator's to execute**, tracked in the runbook. Verification performed: `grep -n 'maxParallel\|shared\|cancel' docs/GatewayTesting.md` shows the new prose; runbook file exists at the path above; no build required (doc-only change).
|
||||
|
||||
**Outcome (2026-08-07 — operator half executed; finding now fully `Done`).** The runner registration left open above was executed per `docs/runbooks/TST-30-second-ci-runner.md` option (a). A second instance-level `act_runner` container, `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/`ubuntu-22.04`), now runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same `container.network: traefik` setting as the original; its registration token is mounted from a `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was **not** modified — capacity went 4 → 6 by addition, so the change is reversible by removing one container. Concurrency verified live by pushing HEAD (`a346d51`) to two scratch branches, `scratch/tst30-a` (run 661) and `scratch/tst30-b` (run 662), while an unrelated run (660) was already in flight: at 13:07:53Z jobs from **three** runs were `in_progress` simultaneously — run 660 `portable` and run 662 `portable`/`java` on runner 1, run 661 `portable`/`java` on `gitea-runner-2` — which the pre-change single-runner topology could not have produced. `gitea:3000` resolution holds on the new instance: run 661's `portable` job (task 1145, scheduled on `gitea-runner-2`) logged `git remote add origin http://gitea:3000/dohertj2/mxaccessgw` followed by a successful `fetch … From http://gitea:3000/dohertj2/mxaccessgw`, and its job container's workspace was confirmed checked out at `a346d514dd24e775640e5667aa7cd8e561fec68a`. **Runbook correction:** its verification checklist said `GET /repos/dohertj2/mxaccessgw/actions/runners` should show ≥2 — that endpoint still returns `total_count: 0` because both runners are registered at the **instance** level, exactly as this finding documented; the correct check is `GET /api/v1/admin/actions/runners`, which lists ids 1, 4 (an unrelated local macOS runner), and 5. Recorded as a dated "Executed" note at the top of the runbook. The no-cancel reality is unchanged and the `run-windev-ci.sh` bypass remains valid, so `docs/GatewayTesting.md`'s prose needed no edit.
|
||||
|
||||
---
|
||||
|
||||
## Cross-domain dependencies
|
||||
|
||||
- **TST-25 → old TST-05 / old TST-24:** the SSH-driven nightly is where the scheduled live-MXAccess smoke lands (closes old TST-05), and a working CI Windows tier unblocks wiring client wire-behavior tests into CI (old TST-24).
|
||||
- **TST-25 → old TST-05 / old TST-24:** the SSH-driven nightly is where the scheduled live-MXAccess smoke lands (closes old TST-05), and a working CI Windows tier unblocks wiring client wire-behavior tests into CI (old TST-24). *Followed up 2026-08-10:* old TST-05 is `Partially done` — the nightly closes the scheduling half, but the live suite still covers none of the five worker **control** commands; old TST-24 is `Done`, and its client wire tests turned out to need no Windows tier at all (they run in the `portable` job). See the first-cycle tracker.
|
||||
- **TST-25 ↔ IPC-24/IPC-25:** the nightly windev job is also the natural home for any Windows-side codegen verification the contracts/IPC remediation adds; coordinate job naming so both plans extend the same `windows-x86`/nightly jobs rather than adding parallel ones.
|
||||
- **TST-26 ⊂ TST-25:** same commit, by rule.
|
||||
- **TST-27:** ships in the cycle's P1 doc-drift batch alongside WRK-26 and CLI-42 (roadmap item 8); its `/browse` residual stays with TST-16 (prior cycle).
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Candidate Findings for the Next Review Cycle (surfaced during 2026-07-12 remediation)
|
||||
|
||||
These were discovered while remediating the 2026-07-12 backlog but were **out of scope** for it — each is either pre-existing, by-design residual, or a new observation. They are recorded here so the next review cycle can triage them. None blocks the 2026-07-12 cycle, which is complete. Rows struck through have since been fixed ahead of that cycle; the original finding text is kept so the triage record stays readable.
|
||||
|
||||
| ID (proposed) | Area | Severity (est.) | Summary |
|
||||
|---|---|---|---|
|
||||
| ~~NEXT-01~~ | Testing / macOS | Low | **Resolved 2026-08-10** — the pipe name is now `mxgw-{pid}-{sessionUid}` (session guid hex, worst-case 43 chars), which fits the 104-byte `sun_path` budget under the default macOS `TMPDIR`; the three test-fixture pipe names were shortened the same way, and a `SessionManagerTests` regression pins the format and length budget. All previously failing suites (SessionWorkerClientFactory, e2e fake-worker smoke, WorkerClient, reconnect-replay) pass 33/33 under the default `TMPDIR` — this also retired the separately-remembered "macOS pipe-timeout test failures", which were this throw misread. Docs updated (gateway.md, GatewayProcessDesign, GatewayConfiguration, Sessions, CLAUDE.md). Original finding: Fake-worker/e2e gateway tests fail on macOS under the default `TMPDIR` because the `CoreFxPipe_mxaccess-gateway-{pid}-{sessionId}` path exceeds the 104-char Unix-domain-socket `sun_path` limit under `/var/folders/…/T/`. Workaround today is `TMPDIR=/tmp`. Fix options: shorten the pipe name, or document the `TMPDIR=/tmp` requirement in `docs/GatewayTesting.md`. Surfaced independently by multiple remediation agents. |
|
||||
| ~~NEXT-02~~ | Clients (.NET, Java) | Low | **Resolved 2026-08-10** — both CLIs now branch on the sentinel and emit the typed cross-CLI row with numeric cursors (Java text mode prints `REPLAY_GAP requested_after=<n> oldest_available=<n>`; .NET emits the `{"replayGap":{…}}` row in jsonl/text and inside the `--json` events array). CrossLanguageSmokeMatrix.md's divergence table collapsed to one converged contract. New CLI regressions in both languages (.NET 35/35, Java 52/52). Original finding: The .NET and Java CLIs render the raw `ReplayGap` sentinel `MxEvent` on `stream-events` instead of a typed gap row — Java text mode prints `0 MX_EVENT_FAMILY_UNSPECIFIED`. Same defect class as CLI-36 (Go) / CLI-35 (Python), which were fixed this cycle; the .NET/Java halves were out of scope. The cross-language smoke matrix now records this divergence honestly. |
|
||||
| ~~NEXT-03~~ | Gateway alarms | Low | **Resolved 2026-08-10** — best-effort dedup in `GatewayAlarmMonitor`: a buffered live transition whose worker timestamp + resulting state the cache already carries from a repair is suppressed, and reconcile Clear repairs tombstone the instance by `original_raise_timestamp` for one reconcile generation so the buffered live Clear dedups too. Positive-match only (unset timestamps never suppress), so the documented at-least-once consumer contract stands; serialization was rejected as the larger change that still needs a worker-side high-water mark to be correct. Two new race-driving regressions; alarm suites 18/18. Original finding: `GatewayAlarmMonitor.ApplyReconcile` feed-repair broadcasts (the new acked-delta from GWC-26 **and** the pre-existing Raise/Clear repair) are **at-least-once, not exactly-once**: a periodic reconcile can synthesize a transition whose matching live transition is still buffered in the alarm lease, so both broadcast as indistinguishable duplicates on the alarm feed (StreamAlarms + dashboard hub). Pre-existing (the Raise/Clear repair always had it); GWC-26 documented the at-least-once contract rather than closing the race. Closing it needs reconcile/live serialization or a monotonic dedup marker. |
|
||||
| ~~NEXT-04~~ | Worker frame writer | Low | **Resolved 2026-08-10** — the tombstone helpers now attach a fault-observing continuation to every frame of a cancelled call (a cancelled task never fires `OnlyOnFaulted`, so unconditional attach is safe; covers both the claimed-mid-write frame and the already-faulted-by-`FailAllQueued` frame where `TrySetCanceled` loses). New regression drives the exact abandonment and asserts a marker exception never reaches `TaskScheduler.UnobservedTaskException`. Original finding: WRK-22/WRK-25 cancellation path: a frame `Claimed` by a concurrent lock-holder just before its caller's cancellation races in is never awaited by that caller; if the write then faults, `TrySetException` lands on a `Task` nobody observes (unobserved-task-exception). By-design residual, non-crash (no `UnobservedTaskException` handler registered), pre-existing to single-frame WRK-22 and amplified per-batch by WRK-25. Hygiene fix: attach a fault-observing continuation to abandoned/tombstoned frame completions. |
|
||||
| ~~NEXT-05~~ | Worker frame writer | Info | **Resolved 2026-08-10 as a documented decision** — the lazy `DequeueNext` purge stays: any subsequent write drains both queues to empty and the heartbeat loop bounds tombstone residency to one interval, while eager `Queue<T>` rebuilds under `_gate` would add ordering-invariant surface next to the WRK-22 interlock for no real gain. Rationale recorded in `docs/WorkerFrameProtocol.md`. Original finding: A batch whose remaining frames are tombstoned by cancellation leaves dead `PendingFrame` entries in `_eventFrames`/`_controlFrames` until a future `DequeueNext` pops and skips them. Same pre-existing behavior as single-frame WRK-22, amplified per-batch; in practice heartbeats purge them promptly, so not a real leak. |
|
||||
| ~~NEXT-06~~ | Testing / live LDAP | Medium | **Resolved 2026-08-07** — fixtures realigned to the shared directory (`admin`/`password` for the GwAdmin success path, `gw-viewer`/`password` for the bind-succeeds-but-no-role path); verified `Failed: 0, Passed: 5` live against the shared GLAuth at `10.100.0.35:3893`, so the success-path assertion (GwAdmin group claim + Admin role claim) now fails if the service-account credential is wrong. Original finding: `DashboardLdapLiveTests` fixtures have drifted from the shared GLAuth directory, leaving the suite with **no positive-proof coverage of the service-account bind**. Its only success-path test, `AuthenticateAsync_AdminInGwAdminGroup_Succeeds`, binds `admin`/`admin123`, but the directory's `admin` user carries the standard dev password (`scadaproj/infra/glauth/config.toml`), so that assertion cannot pass. `AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails` binds fixture user `readonly`, which **does not exist** in the GLAuth config at all — it passes for the wrong reason (user-not-found rather than the group-missing branch it names; the `readonly` name is in fact barred by the README's user/group case-collision rule). The three remaining tests are negative assertions that pass whether or not the service account can bind. Net effect: a green `DashboardLdapLiveTests` run proves nothing about the bind credential — surfaced during SEC-36, where the suite was considered as a substitute for the deferred dashboard-login check and rejected. Fix: realign the fixtures to real directory users (e.g. `multi-role`/`gw-viewer`) or add the missing users to the GLAuth config, and add one test that fails when the service-account credential is wrong. |
|
||||
| ~~NEXT-07~~ | Deployment / windev | High | **Resolved 2026-08-07** — a fresh portable framework-dependent publish of `origin/main` (`a346d51`) was built in a clean clone at `C:\build\mxgw-redeploy`, deployed to `C:\publish\mxaccessgw\Server-20260807`, and the `MxAccessGw` NSSM service repointed at it; the service now holds a stable PID with both `5120`/`5130` listening, a worker spawned, the Galaxy snapshot restored (129 objects / 56,731 attributes) and a clean event log. Root cause confirmed as the version skew this row predicted: the deployed 2026-06-25 build carried `ZB.MOM.WW.Auth.ApiKeys` 0.1.2.0, which supports auth-DB schema 2, against a `gateway-auth.db` stamped at schema 3 on 2026-07-15 by an ephemeral run of newer code — schema 3 is the current shared-lib version (`SqliteAuthSchema.CurrentVersion=3` in Auth 0.1.5), so the redeploy is the forward fix and the DB was left alone. Rollback artifacts kept: `C:\ProgramData\MxGateway\gateway-auth.db.bak-next07` (with `-wal`/`-shm`) and the previous `C:\publish\mxaccessgw\Server` directory. Two side effects worth recording: the old deploy's `appsettings.json` held the LDAP bind password in **plaintext on disk**, while the new one keeps the repo's `${secret:ldap/mxgateway/bind}` token with the NSSM environment supplying the value, so no plaintext LDAP secret remains on that host; and the redeploy tripped the SEC-06 `Ldap:Transport=None` production hard-stop (`GatewayOptionsValidator.cs:178`), resolved by relabelling the host — windev runs `Dashboard:DisableLogin=true`, which this repo's own docs mark dev/test-only, so its `Production` label contradicted its configuration and `DOTNET_ENVIRONMENT` was changed to `Staging` (that one NSSM environment entry only; the other nine preserved byte-identical). SEC-06 is untouched for genuinely production hosts — see NEXT-08 for the posture problem that relabelling defers. Original finding: The `10.100.0.48` (windev) gateway deployment is **stale and crash-looping**, and has been since at least 2026-08-06 (~10k Hosting-failed events/day). The deployed Server binary dates to 2026-06-25 and predates the auth-DB migration of 2026-07-15: it opens a schema-version-3 `gateway-auth.db` that it supports only at version 2 and aborts at startup, so the `MxAccessGw` service never reaches a listening state. Not a code defect in the current tree — a deploy-drift/operations gap — but it means the repo's only deployed host has been dark for over a day and any host-level verification (including SEC-36's dashboard-login check) is blocked until it is repaired. Fix: deploy a current Server build to windev, or restore/downgrade the auth DB to schema 2 if the old binary must stand. Worth asking separately why a service in a permanent restart loop raised no alert. Discovered during SEC-36. |
|
||||
| NEXT-08 | Security / LDAP posture | Medium | **The shared GLAuth offers no TLS, so SEC-06 makes it undeployable from a `Production`-labelled host.** `GatewayOptionsValidator` (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:178`) refuses to start when `Ldap:Transport=None` in the `Production` environment, and `docs/GatewayConfiguration.md`'s `Transport` row states "Deployed hosts must set `Ldaps` or `StartTls`" — but the shared instance at `10.100.0.35:3893` has `[ldaps] enabled=false`, port `3894` closed, and answers StartTLS with `protocolError`, so neither value can work against it. That instruction is currently unsatisfiable for every host that authenticates there. windev sidestepped it on 2026-08-07 by moving to the `Staging` environment name (NEXT-07), which is honest for a dev/test rig but is not available to a real production host. Resolution needs either LDAPS/StartTLS on the shared GLAuth (certificate plus a trust story on each gateway host) or an explicit written posture decision that production gateways bind a different, TLS-capable directory. Surfaced during the NEXT-07 redeploy. |
|
||||
| ~~NEXT-09~~ | Build / versioning | Low | **Resolved 2026-08-10** — the Exec path now appends `.` so the trailing backslash can no longer escape the closing quote, and `SourceRevisionId` is additionally gated on a short-SHA regex so no future git failure text can be stamped either. macOS stamp verified unchanged; Windows stamp verified on windev with this batch. Original finding: **Windows builds stamp git's error text into `InformationalVersion`.** `src/Directory.Build.props:29` runs `git -C "$(MSBuildThisFileDirectory)" …`; MSBuild's directory property ends in a backslash, which escapes the closing quote, so the command is malformed on Windows. The target carries `ContinueOnError`, so the failure is silent and git's stderr is captured as the revision — an observed stamp reads `0.1.2+fatal: cannot change to …`. Any Windows build without a preset `SourceRevisionId` therefore ships a binary that cannot be correlated back to a commit, defeating the point of TST-11. Not reproducible on macOS/Linux, where the separator is `/`. Fix sketch: append `.` to the path or trim the trailing separator before quoting. Surfaced while identifying the deployed binary during NEXT-07. |
|
||||
| NEXT-10 | Docs / glauth | Medium | **`glauth.md`'s "Pre-provisioned users" table contradicts both the directory and the rest of its own file.** It documents `readonly`/`readonly123` and `admin`/`admin123`, neither of which matches `scadaproj/infra/glauth/config.toml` (`readonly` does not exist there; `admin` carries the standard dev password), and lists the `ReadOnly` gid as `5501` against an actual `5601`. Its dashboard section, by contrast, is correct — so the file is internally inconsistent and a reader cannot tell which half to trust. This table was the **root cause of the NEXT-06 fixture drift**, and it has propagated further: `docs/GatewayTesting.md`'s `MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD` default and the matching literal in `WorkerLiveMxAccessSmokeTests` both take `admin123` from it. Deliberately **not** fixed in the 2026-08-07 pass: the table is entangled with the OPC-UA group taxonomy (gids, role mapping, and the sister-repo consumers of the same directory), so reconciling it means sweeping that taxonomy as one unit rather than patching two rows. |
|
||||
|
||||
## Operator actions still pending (from this cycle's runbooks)
|
||||
|
||||
These are **live-infrastructure actions the operator must execute** — the repo-side work is complete and merged:
|
||||
|
||||
- ~~**SEC-36** — rotate the dev LDAP service-account credential per `docs/runbooks/SEC-36-ldap-credential-rotation.md` (generate new secret in `scadaproj/infra/glauth`, pre-stage the NSSM env var on deployed hosts, rotate GLAuth on `10.100.0.35`, verify dashboard login). The committed literal is gone from the working tree but remains recoverable from git history until rotation completes — **rotation is the load-bearing half.**~~ **Executed 2026-08-07**: the `serviceaccount` `passsha256` was replaced in `scadaproj/infra/glauth/config.toml` (commit `aada53b`) and the shared GLAuth recreated on `10.100.0.35`, so the literal recoverable from this repo's history no longer binds `dc=zb,dc=local`. The new value lives only in the GLAuth hash, windev's NSSM environment, and dev user-secrets. `wonder-app-vd03` was out of scope (it binds a different, `dc=scadalink`/`dc=scadabridge` directory). **Caveat, since closed:** windev's dashboard `/login` verification was deferred while that host's gateway was crash-looping on the unrelated stale-deployment fault filed as NEXT-07, so the bind was verified directly by `ldapsearch` as `cn=serviceaccount,dc=zb,dc=local` instead. After the 2026-08-07 redeploy the real check ran on windev (login as `multi-role` → 302 + dashboard cookie, anonymous control → `/login`), so the rotated credential is now proven through the `DashboardAuthenticator` path itself; see `docs/runbooks/SEC-36-ldap-credential-rotation.md` Correction 3. SEC-36 is fully `Done`.
|
||||
- ~~**TST-30** — register a second Gitea `act_runner` on `10.100.0.35` per `docs/runbooks/TST-30-second-ci-runner.md` to relieve the single-shared-runner bottleneck.~~ **Executed 2026-08-07**: `gitea-runner-2` (id 5, capacity 2) is online on `10.100.0.35` via the `/opt/gitea` compose stack, same `container.network: traefik`, token from a `0600` file mount; the existing runner (id 1, capacity 4) was untouched. Concurrency verified — jobs from three runs ran simultaneously across both runners, and a `gitea-runner-2` job cloned successfully from `http://gitea:3000`. TST-30 is now fully `Done`.
|
||||
- **TST-30 follow-up — reset the Gitea instance runner registration token.** Runner-1's compose block was moved to the same `0600` file-mount pattern as runner-2 on 2026-08-07 (compose and both backups now `0600 root:root`, runner-1 recreated with its identity intact), but hygiene alone does not retire the token: both runners share **one instance-scope registration token** that was world-readable for roughly five months and is still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 exposes no rotation via CLI or API (both paths are get-or-create and hand back the same value), so the reset must be done in the admin web UI ("Reset registration token"). Afterwards, refresh `/opt/gitea/runner_token` on `10.100.0.35` and shred the two token-bearing compose backups — they are the last copies of the old value. See `docs/runbooks/TST-30-second-ci-runner.md`.
|
||||
- **TST-25 follow-ups** — old **TST-05** (scheduled live-MXAccess smoke) is now covered by the `nightly-windev` job; old **TST-24** (client wire tests in CI) is unblocked by the working Windows tier.
|
||||
@@ -49,7 +49,7 @@ Impact: logout (`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:136-155`)
|
||||
Recommendation: keep the lifetime short (or shorten to ~5 minutes given the factory refreshes per reconnect, `docs/GatewayDashboardDesign.md:497-499`), and confirm no request-path logging captures query strings (Serilog request logging is not currently enabled; keep it that way or scrub `access_token`).
|
||||
|
||||
**SEC-6 · Medium — LDAP is plaintext-by-default with a committed service-account password.**
|
||||
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:49-61` (defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword = "serviceaccount123"`), `appsettings.json:21-33` (same values checked into the repo), `glauth.md:30,327` (dev LDAPS disabled; "binding sends passwords cleartext on the wire").
|
||||
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:49-61` (defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword = "<service-account-password>"` — value redacted per SEC-36), `appsettings.json:21-33` (same values checked into the repo), `glauth.md:30,327` (dev LDAPS disabled; "binding sends passwords cleartext on the wire").
|
||||
Impact: every dashboard login sends the operator's password in cleartext to `10.100.0.35:3893`, and the LDAP service-account credential is in source control. This is a documented dev posture (the shadow-options rationale at `LdapOptions.cs:20-28` is explicit that the shared library is secure-by-default), and the validator does enforce the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) — but nothing distinguishes dev from prod at runtime.
|
||||
Recommendation: for production deployment docs, require `Transport=Ldaps`/`StartTls` + `AllowInsecure=false` and move `ServiceAccountPassword` to env-var/secret configuration; consider an `IsProduction` startup check mirroring SEC-4. LDAP injection risk is delegated to the shared `ZB.MOM.WW.Auth.Ldap` provider (bind-then-search per `Dashboard/DashboardAuthenticator.cs:41-47`); its escaping cannot be verified from this repo — flag for review in the donor repo.
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| CLI-21 | Low | P2 | S | — | Done | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases |
|
||||
| CLI-22 | Low | — | S | — | Not started | Go `newCorrelationID` swallows `crypto/rand` error → empty id |
|
||||
| CLI-23 | Low | — | S | — | Not started | Go nil-vs-empty bulk short-circuit asymmetry |
|
||||
| CLI-24 | Low | — | S | — | Not started | Java `MxEventStream` single-consumer constraint undocumented |
|
||||
| CLI-24 | Low | — | S | — | Done | Java `MxEventStream` single-consumer constraint undocumented (closed 2026-08-07 per 2026-07-12 review old-tracker action; documented at MxEventStream.java:25 "Single consumer") |
|
||||
| CLI-25 | Low | — | S | — | Not started | Java `close()` does not await channel termination |
|
||||
| CLI-26 | Low | P2 | S | — | Done | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) |
|
||||
| CLI-27 | Low | — | S | — | Not started | Python `Session.close()` not concurrency-safe; synthesizes reply |
|
||||
@@ -207,7 +207,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| CLI-31 | Low | — | M | — | Not started | Rust CLI is a single 2,699-line `main.rs` |
|
||||
| CLI-32 | Low | — | S | — | Not started | Client-side bulk caps differ (.NET/Java unbounded) |
|
||||
| CLI-33 | Low | — | S | CLI-01,13 | Not started | Per-language event backpressure semantics undocumented |
|
||||
| CLI-34 | Low | — | S | — | Not started | Python `build/`/`.pytest_cache/` present on disk (untracked) |
|
||||
| CLI-34 | Low | — | S | — | Done | Python `build/`/`.pytest_cache/` present on disk (untracked) (closed 2026-08-07 per 2026-07-12 review old-tracker action; both gitignored in clients/python/.gitignore) |
|
||||
|
||||
### Testing, docs & gaps — [60-testing-docs-gaps.md](60-testing-docs-gaps.md)
|
||||
|
||||
@@ -217,7 +217,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| TST-02 | High | P0 | M | TST-04 | Done | Reconnect owner re-validation not implemented |
|
||||
| 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 | Not started | Real-worker control/COM paths verified opt-in only |
|
||||
| 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-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) |
|
||||
@@ -236,7 +236,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
||||
| TST-21 | Low | — | S | — | Not started | Log rotation configured but minimal |
|
||||
| TST-22 | Low | — | S | — | Not started | Config-shape JSON block omits documented keys |
|
||||
| TST-23 | Low | P2 | S | — | Done | Bidirectional `Session` RPC never built |
|
||||
| TST-24 | Low | P2 | M | TST-03 | Not started | Client wire behaviour has no automated verification. **Gate cleared:** TST-03 CI is Done (live and green 2026-07-10; Windows/x86 tier green 2026-07-13 via the TST-25/TST-26 SSH-driven windev job), so TST-24 is unblocked — deferred by choice now, not CI-gated |
|
||||
| TST-24 | Low | P2 | M | TST-03 | Done | Client wire behaviour has no automated verification — closed 2026-08-10. Go/Rust/Java already had real-server wire tests (the finding's premise was stale); the genuine gaps were .NET (transport-interface fake everywhere, no server package) and Python (stub monkeypatch everywhere but one opt-in TLS test). Added `MxGatewayClientWireTests` + `WireFakeGatewayServer` (Kestrel h2c) and `tests/test_wire_fake_gateway.py` (`grpc.aio` loopback), plus a `dotnet test` step for the .NET client in the `portable` CI job. Caught a real bug: Python `connect()` built the `grpc.aio` channel inside `asyncio.to_thread` and failed for every non-stub connection |
|
||||
|
||||
## Cross-cutting clusters
|
||||
|
||||
@@ -253,6 +253,9 @@ Findings the review flagged as one coordinated design pass — sequence them tog
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-08-10 | **TST-25 acceptance Check 6 (forced-failure nightly issue) → Done.** The 2026-07-13 record wrote this check off as "abandoned to shared-runner congestion"; that was wrong on both counts. The 2026-07-13 probe *did* land (issue #125, `[CHECK6 PROBE]`, run 375), and since 2026-07-17 the `nightly-windev` `if: failure()` step has filed an issue on **every** red nightly — #126–#139, all authored by the `gitea-actions` bot. Traced run 672 (schedule, main, red) line by line: main step fails → `exitcode '1': failure` → the `if: failure()` step runs → `POST /api/v1/repos/dohertj2/mxaccessgw/issues` with the built-in token masked to `***` → issue #139 created at the matching timestamp. Re-confirmed by a fresh forced-failure probe on the throwaway branch `test/tst25-check6-nightly-issue` (temporary `tst25-check6-probe.yml` reproducing the job shape with `exit 1` for the live step; run 677 → issue #140). Branch deleted, issues #125 and #140 closed with explanatory comments. **One real defect found and fixed** (`fix/tst25-nightly-issue-path`, not merged): `${{ github.server_url }}` is the runner-internal `http://gitea:3000`, so every filed issue's run link was unreachable from a browser. The API call must keep using it (the job container resolves `gitea` only on the docker network and has no LAN egress to the public origin), so the fix adds a `PUBLIC_SERVER_URL: https://gitea.dohertylan.com` job env used **only** for the browser-facing link in the issue body; the probe validated the fixed template (#140 carries a `https://gitea.dohertylan.com/...` link that returns 200). **Separately observed, not fixed:** the nightly has been red continuously since at least 2026-07-17 (run 672: `x86 Worker.Tests failed with exit code 1`, 1 failed / 398 passed / 11 skipped — the known `EventBurst_DrainLoopCoalescesFlushes` class of flake), and the step de-duplicates nothing, so 14 issues are open, seven of them (#132–#138) for the identical SHA `47c0b64`. Worth a follow-up: fix the red nightly, and consider having the step reuse an open issue with the same title instead of filing a new one. |
|
||||
| 2026-08-10 | **TST-24 → `Done`: per-client wire tests land for the two clients that lacked them** (branch `feat/tst-24-client-wire-tests`). Audit first corrected the finding's premise: **Go, Rust, and Java already had real-server wire tests** — `newBufconnClient`/`fakeGatewayServer` over `grpc/test/bufconn`, `spawn_fake_gateway` over a loopback `TcpListener` with tonic's `Server`, and `InProcessGateway`/`TestGatewayService` over `InProcessServerBuilder` — each already asserting the round trip, the server-observed `authorization` bearer header, and the `ReplayGap` sentinel. The real gaps were **.NET** (every test substituted `FakeGatewayTransport` for `IMxGatewayClientTransport`, and the test project had no server package) and **Python** (stub monkeypatching everywhere except one opt-in TLS test serving only `OpenSession`). Added `WireFakeGatewayServer` + `MxGatewayClientWireTests` (Kestrel h2c on `127.0.0.1:0` serving `MxAccessGatewayBase`; new `Grpc.AspNetCore.Server` 2.76.0 + `Microsoft.AspNetCore.App` refs on the test project) and `clients/python/tests/test_wire_fake_gateway.py` (`grpc.aio` server on `127.0.0.1:0`, no new deps). Four shapes each: full round trip with every reply field asserted, the bearer header **as received by the server** on the streaming RPC too, the `ReplayGap` sentinel surfaced as the client's typed signal, and a genuine `PERMISSION_DENIED` mapping to the typed authorization error. CI: the `portable` job only *built* the .NET client, so a `dotnet test` step was added. **The new tests immediately caught a shipped bug** — Python `GatewayClient.connect()`/`GalaxyRepositoryClient.connect()` constructed the `grpc.aio` channel inside `asyncio.to_thread`, which raises `RuntimeError: There is no current event loop in thread 'asyncio_0'` because a `grpc.aio` channel binds to the loop current on the constructing thread; every non-stub connection failed, and the one test guarding the off-loop behaviour (Client.Python-028) monkeypatched `create_channel` and so asserted the bug. Fixed by splitting `resolve_channel_security` (blocking TOFU probe, off-loop) from `create_channel` (on-loop), with the `-028` tests retargeted to assert both halves. Verified: .NET 133 passed/1 skipped (pre-existing live-gateway skip), Python 168 passed/1 skipped plus 6/6 opt-in TLS. Docs: `docs/GatewayTesting.md` § Client Wire Tests, `clients/dotnet/README.md`, `clients/python/README.md`. |
|
||||
| 2026-08-10 | **TST-05 revisited under the restored Windows tier → `Partially done`** (branch `feat/tst-24-client-wire-tests`, doc/tracker-only). The finding's **scheduling** half is closed: cycle-2 TST-25's `nightly-windev` job (cron `0 6 * * *`) runs `scripts/ci/run-windev-ci.sh live` → `windev-worker-ci.ps1 -Mode live`, which sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, runs `WorkerLiveMxAccessSmokeTests` on windev after the x86 build/Worker.Tests/full-slnx steps, and files a Gitea issue when red. The **coverage-audit** half is *not* closed, and the audit the design asked for now has a negative answer: the suite's eight `[LiveMxAccessFact]`s cover all six late-added COM commands (`Suspend`, `Activate`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`) but zero of the five control commands — `MxCommandKind.{Ping,GetSessionState,GetWorkerInfo,DrainEvents,ShutdownWorker}` appear nowhere in `WorkerLiveMxAccessSmokeTests.cs`, so the exact paths the Finding calls masked are still only proven against `FakeWorkerHarness` canned replies while the real implementations live in `Worker/Ipc/WorkerPipeSession.cs`. Residual work (two `[LiveMxAccessFact]`s, windev-only to author and verify) is specified in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-05--real-worker-controlcom-paths-verified-opt-in-only---medium--p1). |
|
||||
| 2026-07-10 | **TST-15 design fleshed out** (still `Not started` — design only, not implementation): `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. Resolves the crux the deferral left open — the dashboard is LDAP-identity (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`), two disjoint identity domains — via a **session tag** sourced from the owning API key (rides in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin-sees-all; Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (new `Dashboard:GroupToTag` map → hub-token tag claims); untagged sessions Admin-only by default (`Dashboard:UntaggedSessionVisibility`). Includes the enforcement path (`HubTokenPayload.Tags` + `IDashboardSessionAcl` gate at `SubscribeSession`), task breakdown (epic Tasks 16–19), test plan incl. live-LDAP, and rejected alternatives (client-supplied tag; group→key-id map). Tracker + `60-testing-docs-gaps.md` TST-15 section point at the doc. **TST-03 investigated:** the CI never ran because the repo had **zero registered Gitea Actions runners** (Actions is enabled; runs are created on push/PR/nightly but fail instantly with nothing to execute them). A Mac runner proved the pipeline executes but cannot clone — this Gitea hands runners the internal `http://gitea:3000` URL, reachable only by a runner co-located on the gitea Docker network. Fix = run a co-located runner on the Gitea host (recipe prepared, `scratchpad/gitea-runner/setup-gitea-host-runner.sh`); pending host access. TST-03 stays `In review`. |
|
||||
| 2026-07-09 | **P2 Epic wrap — user decision: DEFER TST-15 + TST-24, close the epic.** Epic bucket result: 5 of 7 findings `Done` (CLI-15, CLI-04, CLI-30, TST-01, TST-04); **TST-15** and **TST-24** intentionally deferred to a follow-up (kept `Not started`, not `Won't fix` — they are gated, not rejected). **TST-15** (dashboard EventsHub per-session ACL) is epic Phase 4 — a real feature needing a new session-"tag" mechanism + dashboard group→tag config, not a mechanical fix; the `EventsHub` `TODO(per-session-acl)` stays, and the already-shipped **SEC-25** mitigation (tag *values* redacted from the dashboard mirror by default) means no sensitive payload leaks through the hub today regardless of the missing ACL — so deferring carries no value-leak risk. **TST-24** (per-client wire tests) depends on **TST-03** (CI), which is `In review` (YAML authored, never run on a Gitea runner) — no point wiring client tests into a pipeline that isn't live yet. Net P2: 35/38 `Done`; remaining = TST-15 (deferred feature), TST-24 (deferred, CI-gated), TST-14 (user deletes their own untracked gitignored `*-docs-*.md` files). |
|
||||
| 2026-07-09 | P2 Epic — **Java client completes CLI-15 + CLI-04 locally** (commit `1cc0fa4`); **CLI-15, CLI-04, CLI-30, TST-01 all → `Done` (5/5 clients + server e2e)**. Java CLI-15: `MxEventStreamItem` record + `MxEventStream.nextItem()` (`isReplayGap()`/`replayGap()`/`event()`); existing `Iterator<MxEvent>` path unchanged, sentinel never swallowed. Java CLI-04: Phase 1 `adviseSupervisory`/`writeSecured`/`writeSecured2`/`authenticateUser`/`archestrAUserToId` + Phase 2 `addBufferedItem`/`setBufferedUpdateInterval`/`suspend`/`activate` (unregister already present) on `MxGatewaySession`, each through `invokeCommand` → `ensureProtocolSuccess`+`ensureMxAccessSuccess`; credentials scrubbed via `MxGatewaySecrets.redactCredentials` (tests assert absent from message/toString/CLI). `gradle test` 106/0 (58 client + 48 cli), no generated churn. Built locally with `JAVA_HOME=/opt/homebrew/opt/openjdk@17` — Java toolchain now works on the Mac (see prior note). Shared docs `ClientLibrariesDesign.md` + CLAUDE.md updated to "all five clients". **TST-01 → Done** (server e2e `fed0685` + all 5 client `ReplayGap` consumers). This closes session-resilience epic Phase 3 fully. |
|
||||
|
||||
@@ -132,7 +132,7 @@ This document turns every finding in the Security/Dashboard/Observability review
|
||||
|
||||
## SEC-06 — LDAP plaintext-by-default with a committed service password `Medium` · `P1`
|
||||
|
||||
**Finding.** *(review SEC-6)* `Configuration/LdapOptions.cs:49-61` defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword="serviceaccount123"`; `appsettings.json:21-33` ships the same. `glauth.md:30,327` confirms dev LDAPS is disabled and binds send cleartext. The validator enforces the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) but nothing distinguishes dev from prod.
|
||||
**Finding.** *(review SEC-6)* `Configuration/LdapOptions.cs:49-61` defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword="<service-account-password>"` (value redacted per SEC-36); `appsettings.json:21-33` ships the same. `glauth.md:30,327` confirms dev LDAPS is disabled and binds send cleartext. The validator enforces the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) but nothing distinguishes dev from prod.
|
||||
|
||||
**Impact.** Every dashboard login sends the operator's password cleartext to `10.100.0.35:3893`, and a service-account credential is in source control.
|
||||
|
||||
@@ -140,7 +140,7 @@ This document turns every finding in the Security/Dashboard/Observability review
|
||||
|
||||
**Implementation.**
|
||||
- `Configuration/GatewayOptionsValidator.cs`: in `ValidateLdap`, when Production and `Transport == None`, emit an error (co-locate with SEC-04's env plumbing).
|
||||
- Deployment: keep `serviceaccount123` only for local GLAuth dev; document env-var override (`MxGateway__Ldap__ServiceAccountPassword`) for the NSSM-wrapped hosts; rotate the dev credential's reuse.
|
||||
- Deployment: keep the dev service-account password only for local GLAuth dev; document env-var override (`MxGateway__Ldap__ServiceAccountPassword`) for the NSSM-wrapped hosts; rotate the dev credential's reuse.
|
||||
- Docs: `docs/GatewayConfiguration.md` Ldap section and a production hardening note referencing `glauth.md`.
|
||||
- Tests: `GatewayOptionsValidatorTests` — `Transport=None` + Production → invalid.
|
||||
|
||||
|
||||
@@ -159,6 +159,14 @@ This finding is the umbrella; TST-01/02/15 are its actionable slices. The remedi
|
||||
|
||||
## TST-05 — Real-worker control/COM paths verified opt-in only `Medium` · `P1`
|
||||
|
||||
> **Status revisit 2026-08-10 (unlocked by TST-25): `Partially done` — one half closed, one half open.**
|
||||
>
|
||||
> **Closed — the scheduled cadence.** The `nightly-windev` job in `.gitea/workflows/ci.yml` (cron `0 6 * * *`, gated `if: github.event_name == 'schedule'`) runs `scripts/ci/run-windev-ci.sh live`, which drives `scripts/ci/windev-worker-ci.ps1 -Mode live` on windev: x86 Worker build → full `Worker.Tests` → full-slnx build → `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1 dotnet test … --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests`. A red nightly opens a Gitea issue, so nobody has to watch the Actions page. That is exactly this finding's **Design** paragraph, and it is the `live-mxaccess` job the design referred to (renamed; the removed job it originally pointed at is gone — see cycle-2 TST-25/TST-26).
|
||||
>
|
||||
> **Open — the coverage audit.** The design also required auditing `WorkerLiveMxAccessSmokeTests.cs` for coverage of *each* of the eleven late-added command kinds and adding missing `[LiveMxAccessFact]` cases. That audit now has an answer, and it is negative for five of the eleven. The suite's eight facts reach all six late-added **COM** commands (`Suspend`, `Activate`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval` — the `NewComCommands_RoundTripWithRealReplies` and `BufferedItem_*` facts). None of them sends any of the five **control** commands: `MxCommandKind.{Ping,GetSessionState,GetWorkerInfo,DrainEvents,ShutdownWorker}` do not appear anywhere in the file. Those are the very kinds the Finding below names as masked. The real worker answers them off-STA in `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs` (dispatch switch at `:574`+), so the nightly exercises that code path only incidentally, never by assertion — a regression in `CreatePingReply`/`CreateSessionStateReply`/`CreateWorkerInfoReply`/the drain snapshot/the shutdown-after-reply ordering still ships green through both CI and the nightly.
|
||||
>
|
||||
> **Residual work to close TST-05 fully** (small, Windows-only): add one `[LiveMxAccessFact]` to `WorkerLiveMxAccessSmokeTests` that, against a live worker, invokes `Ping` → `GetSessionState` → `GetWorkerInfo` → `DrainEvents` and asserts each returns a non-`INVALID_REQUEST` reply carrying real worker state (e.g. `worker_process_id` matching the launched process), plus a separate fact for `ShutdownWorker` asserting the OK reply arrives *before* the worker exits and the session is then faulted/closed. `ShutdownWorker` needs `admin` scope and terminates the worker, so it must be the last fact in its own fixture. Not done here because it can only be authored and verified on windev with MXAccess installed; this revisit is doc/tracker-only.
|
||||
|
||||
**Finding.** All eleven late-added command kinds are unit-tested against fakes and live-verified once on the dev rig (`stillpending.md` §1.1), but the default suite exercises `Ping`/`GetWorkerInfo`/`DrainEvents`/`ShutdownWorker` only through `FakeWorkerHarness.RespondToControlCommandAsync` (verify current line range in `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs`), which returns canned replies.
|
||||
|
||||
**Impact.** A worker-side regression in these paths is invisible until someone sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`.
|
||||
@@ -449,6 +457,24 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
|
||||
|
||||
## TST-24 — Client wire behaviour has no automated verification `Low` · `—`
|
||||
|
||||
> **Resolution 2026-08-10 (branch `feat/tst-24-client-wire-tests`): `Done`.** All five clients now drive their public API against a fake gateway served over a real gRPC transport, in the client's own default suite, and all five run in CI.
|
||||
>
|
||||
> **Corrected premise.** The Finding's "no in-process gateway integration tests" was already stale when it was re-verified: **Go, Rust, and Java had real-server wire tests**, not mocks — Go's `newBufconnClient`/`fakeGatewayServer` (`clients/go/mxgateway/client_session_test.go`) over `grpc/test/bufconn`, Rust's `spawn_fake_gateway` (`clients/rust/tests/client_behavior.rs`) over a loopback `TcpListener` with tonic's `Server`, and Java's `InProcessGateway`/`TestGatewayService` (`MxGatewayClientSessionTests.java`) over `InProcessServerBuilder`. Each already asserted the round trip, the `authorization` bearer header as *observed by the server*, and the `ReplayGap` sentinel. The cycle-2 re-verification cited `clients/python/tests/test_replay_gap.py` as evidence for "the other four clients still unit-test against mocks"; that generalized from Python to Go/Rust/Java incorrectly. `InProcessGatewayHarness` (the "template" the Impact paragraph names) is in fact the *thinner* of the Java harnesses — it serves only `streamEvents`/`closeSession` for the CLI tests.
|
||||
>
|
||||
> **Real gap, and what was built.** Two clients genuinely had none. **.NET** substituted `FakeGatewayTransport` for `IMxGatewayClientTransport` in every test, so not even the generated stub ran, and its test project had no server package at all. **Python** monkeypatched `MxAccessGatewayStub` everywhere except one opt-in TLS test that served only `OpenSession`. Both now have the pattern:
|
||||
> - `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/WireFakeGatewayServer.cs` + `MxGatewayClientWireTests.cs` — Kestrel h2c on `127.0.0.1:0` serving `MxAccessGateway.MxAccessGatewayBase`; needed new `Grpc.AspNetCore.Server` + `Microsoft.AspNetCore.App` references on the test project.
|
||||
> - `clients/python/tests/test_wire_fake_gateway.py` — a `grpc.aio` server on `127.0.0.1:0` serving `MxAccessGatewayServicer`; no new dependencies (`grpcio` is a runtime dep).
|
||||
>
|
||||
> Each covers the four shapes the Design asked for: round trip (`OpenSession` → `Invoke`/`Register` → `StreamEvents` → `CloseSession` with every reply field asserted), the bearer header as received by the server on the streaming RPC as well as the unary ones, the `ReplayGap` sentinel surfaced as the client's typed signal (TST-01), and a real `PERMISSION_DENIED` mapping to the typed authorization error.
|
||||
>
|
||||
> **CI.** The `portable` job previously only *built* the .NET client; a `dotnet test` step was added, so its wire tests actually run. Go/Rust/Python already ran their suites there and Java in the `java` job.
|
||||
>
|
||||
> **Bug this immediately caught** — the justification for the whole finding. `GatewayClient.connect()` / `GalaxyRepositoryClient.connect()` in the Python client were **broken for every real (non-stub) connection**: they 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 the worker thread raised `RuntimeError: There is no current event loop in thread 'asyncio_0'`. No mock-based test could see it — the one test asserting the off-loop behaviour (`Client.Python-028`) monkeypatched `create_channel` and therefore asserted the bug. Fixed by splitting `resolve_channel_security` (blocking TOFU probe, runs off-loop) from `create_channel` (must run on the loop thread), keeping the Client.Python-028 guarantee; the two `-028` tests were retargeted to assert both halves.
|
||||
>
|
||||
> **Deliberately out of scope.** Only the four session RPCs are served — the alarm feed (`StreamAlarms`, `QueryActiveAlarms`, `AcknowledgeAlarm`) and Galaxy browse are not, matching the Design's "full parity is out of scope". Java's `InProcessGatewayHarness` still lacks `openSession`/`invoke`; the client-module tests cover those shapes, so it was left alone.
|
||||
>
|
||||
> **Docs.** `docs/GatewayTesting.md` § Client Wire Tests (the cross-client pattern + per-client harness table), `clients/dotnet/README.md`, `clients/python/README.md`.
|
||||
|
||||
**Finding.** All five clients have unit tests (13/8/3/13/7 files for dotnet/go/rust/python/java) but no in-process or containerized gateway integration tests; the only cross-language verification is the operator-run `scripts/run-client-e2e-tests.ps1`. `CrossLanguageSmokeMatrixTests` checks shapes only.
|
||||
|
||||
**Impact.** Low-to-moderate: a gateway contract change can pass every default suite and break all five clients (partly mitigated by shared-proto codegen). The Java CLI already proves the cheap pattern — `InProcessGatewayHarness` (`stillpending.md` §8).
|
||||
|
||||
@@ -23,6 +23,20 @@ dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
|
||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
|
||||
```
|
||||
|
||||
Most tests substitute `FakeGatewayTransport` for `IMxGatewayClientTransport`, so
|
||||
they never touch the wire. `MxGatewayClientWireTests` is the exception: it drives
|
||||
the ordinary public API against `WireFakeGatewayServer`, a real gRPC server
|
||||
(Kestrel h2c on an ephemeral loopback port) serving
|
||||
`MxAccessGateway.MxAccessGatewayBase`. Only the gateway's behaviour is canned —
|
||||
the HTTP/2 framing, protobuf serialization, `authorization` metadata, and gRPC
|
||||
status codes are genuine, so it catches decode and metadata breaks a transport
|
||||
fake cannot see. No MXAccess or worker is involved; it runs in the default suite.
|
||||
See `docs/GatewayTesting.md` (Client Wire Tests) for the cross-client pattern.
|
||||
|
||||
```powershell
|
||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter FullyQualifiedName~MxGatewayClientWireTests
|
||||
```
|
||||
|
||||
## Packaging
|
||||
|
||||
Create local library and CLI artifacts from the repository root:
|
||||
@@ -487,7 +501,7 @@ dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget
|
||||
Then add the package to your project:
|
||||
|
||||
````bash
|
||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.1
|
||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.2.0
|
||||
````
|
||||
|
||||
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||
|
||||
@@ -1418,14 +1418,16 @@ public static class MxGatewayClientCli
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (jsonLines)
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
}
|
||||
else if (json)
|
||||
if (json && !jsonLines)
|
||||
{
|
||||
events.Add(gatewayEvent);
|
||||
}
|
||||
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).
|
||||
output.WriteLine(FormatReplayGapRow(replayGap));
|
||||
}
|
||||
else
|
||||
{
|
||||
output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent));
|
||||
@@ -1835,7 +1837,31 @@ public static class MxGatewayClientCli
|
||||
|
||||
private static JsonElement EventToJsonElement(MxEvent gatewayEvent)
|
||||
{
|
||||
return JsonDocument.Parse(ProtobufJsonFormatter.Format(gatewayEvent)).RootElement.Clone();
|
||||
string row = gatewayEvent.ReplayGap is { } replayGap
|
||||
? FormatReplayGapRow(replayGap)
|
||||
: ProtobufJsonFormatter.Format(gatewayEvent);
|
||||
return JsonDocument.Parse(row).RootElement.Clone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the
|
||||
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
|
||||
/// formatter's quoted uint64 strings.
|
||||
/// </summary>
|
||||
/// <param name="replayGap">Replay gap sentinel payload.</param>
|
||||
/// <returns>A single-line JSON row describing the gap.</returns>
|
||||
private static string FormatReplayGapRow(ReplayGap replayGap)
|
||||
{
|
||||
return JsonSerializer.Serialize(
|
||||
new
|
||||
{
|
||||
replayGap = new
|
||||
{
|
||||
requestedAfterSequence = replayGap.RequestedAfterSequence,
|
||||
oldestAvailableSequence = replayGap.OldestAvailableSequence,
|
||||
},
|
||||
},
|
||||
JsonOptions);
|
||||
}
|
||||
|
||||
private static MxValue ParseValue(CliArguments arguments)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Client.Cli;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
@@ -585,6 +586,84 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamEvents_RendersReplayGapAsTypedRow()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.Events.Add(new MxEvent
|
||||
{
|
||||
ReplayGap = new ReplayGap
|
||||
{
|
||||
RequestedAfterSequence = 7,
|
||||
OldestAvailableSequence = 42,
|
||||
},
|
||||
});
|
||||
fakeClient.Events.Add(new MxEvent
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
WorkerSequence = 43,
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"stream-events",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--session-id",
|
||||
"session-fixture",
|
||||
"--max-events",
|
||||
"2",
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
string[] rows = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(2, rows.Length);
|
||||
using JsonDocument gapRow = JsonDocument.Parse(rows[0]);
|
||||
JsonElement gap = gapRow.RootElement.GetProperty("replayGap");
|
||||
Assert.Equal(7UL, gap.GetProperty("requestedAfterSequence").GetUInt64());
|
||||
Assert.Equal(42UL, gap.GetProperty("oldestAvailableSequence").GetUInt64());
|
||||
Assert.Equal(JsonValueKind.Number, gap.GetProperty("requestedAfterSequence").ValueKind);
|
||||
Assert.DoesNotContain("MX_EVENT_FAMILY_UNSPECIFIED", rows[0], StringComparison.Ordinal);
|
||||
Assert.Contains("workerSequence", rows[1], StringComparison.Ordinal);
|
||||
|
||||
// The aggregate --json shape carries the same typed row inside the events array.
|
||||
using var aggregateOutput = new StringWriter();
|
||||
int aggregateExit = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"stream-events",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key",
|
||||
"test-api-key",
|
||||
"--session-id",
|
||||
"session-fixture",
|
||||
"--max-events",
|
||||
"2",
|
||||
"--json",
|
||||
],
|
||||
aggregateOutput,
|
||||
error,
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, aggregateExit);
|
||||
using JsonDocument aggregate = JsonDocument.Parse(aggregateOutput.ToString());
|
||||
JsonElement firstRow = aggregate.RootElement.GetProperty("events")[0];
|
||||
Assert.Equal(42UL, firstRow.GetProperty("replayGap").GetProperty("oldestAvailableSequence").GetUInt64());
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the public client API against <see cref="WireFakeGatewayServer"/> — a real
|
||||
/// gRPC server on loopback — so the transport, protobuf serialization, call metadata,
|
||||
/// and gRPC status mapping are all exercised. Every other test in this project
|
||||
/// substitutes <see cref="FakeGatewayTransport"/> and therefore proves nothing about
|
||||
/// what actually crosses the wire.
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClientWireTests
|
||||
{
|
||||
private const string ApiKey = "mxgw_wiretest_secret";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the full session happy path decodes real wire bytes end to end.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SessionRoundTrip_OverRealTransport_DecodesEveryReplyField()
|
||||
{
|
||||
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync();
|
||||
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
||||
|
||||
MxGatewaySession session = await client.OpenSessionAsync(
|
||||
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
||||
|
||||
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.SessionId, session.SessionId);
|
||||
Assert.Equal("fake-backend", session.OpenSessionReply.BackendName);
|
||||
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
|
||||
Assert.Equal(3u, session.OpenSessionReply.GatewayProtocolVersion);
|
||||
Assert.Equal(["events", "invoke"], session.OpenSessionReply.Capabilities);
|
||||
|
||||
int serverHandle = await session.RegisterAsync("wire-test-client");
|
||||
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ServerHandle, serverHandle);
|
||||
|
||||
MxCommandRequest? invoke = server.Service.InvokeRequest;
|
||||
Assert.NotNull(invoke);
|
||||
Assert.Equal(MxCommandKind.Register, invoke.Command.Kind);
|
||||
Assert.Equal("wire-test-client", invoke.Command.Register.ClientName);
|
||||
|
||||
List<MxEvent> events = await CollectAsync(client.StreamEventsAsync(
|
||||
new StreamEventsRequest { SessionId = session.SessionId }));
|
||||
|
||||
MxEvent single = Assert.Single(events);
|
||||
Assert.Equal(MxEventFamily.OnDataChange, single.Family);
|
||||
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ServerHandle, single.ServerHandle);
|
||||
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ItemHandle, single.ItemHandle);
|
||||
Assert.Equal(17, single.Value.Int32Value);
|
||||
Assert.Equal(192, single.Quality);
|
||||
Assert.Equal(9ul, single.WorkerSequence);
|
||||
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, single.BodyCase);
|
||||
|
||||
CloseSessionReply closeReply = await session.CloseAsync();
|
||||
Assert.Equal(SessionState.Closed, closeReply.FinalState);
|
||||
Assert.Equal(
|
||||
WireFakeGatewayServer.FakeGatewayService.SessionId,
|
||||
server.Service.CloseSessionRequest?.SessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the API key reaches the server as a bearer header on unary and
|
||||
/// streaming calls alike. A transport fake can only assert what the client passes;
|
||||
/// this asserts what the server receives.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ApiKey_ReachesTheServerAsBearerMetadata_OnEveryRpc()
|
||||
{
|
||||
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync();
|
||||
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
||||
|
||||
MxGatewaySession session = await client.OpenSessionAsync(
|
||||
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
||||
await session.RegisterAsync("wire-test-client");
|
||||
await CollectAsync(client.StreamEventsAsync(
|
||||
new StreamEventsRequest { SessionId = session.SessionId }));
|
||||
await session.CloseAsync();
|
||||
|
||||
string expected = $"Bearer {ApiKey}";
|
||||
Assert.Equal(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["OpenSession"] = expected,
|
||||
["Invoke"] = expected,
|
||||
["StreamEvents"] = expected,
|
||||
["CloseSession"] = expected,
|
||||
},
|
||||
server.Service.AuthorizationByMethod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the gateway's replay-gap sentinel survives serialization and is
|
||||
/// surfaced as a typed, non-terminal stream item.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReplayGapSentinel_SurvivesTheWire_AsTypedStreamItem()
|
||||
{
|
||||
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(service =>
|
||||
service.ReplayGap = new ReplayGap
|
||||
{
|
||||
RequestedAfterSequence = 3,
|
||||
OldestAvailableSequence = 8,
|
||||
});
|
||||
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
||||
|
||||
MxGatewaySession session = await client.OpenSessionAsync(
|
||||
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
||||
|
||||
List<MxEventStreamItem> items = [];
|
||||
IAsyncEnumerable<MxEvent> stream = client.StreamEventsAsync(new StreamEventsRequest
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
AfterWorkerSequence = 3,
|
||||
});
|
||||
|
||||
await foreach (MxEventStreamItem item in stream.AsStreamItemsAsync())
|
||||
{
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.True(items[0].IsReplayGap);
|
||||
Assert.Equal(3ul, items[0].ReplayGap!.RequestedAfterSequence);
|
||||
Assert.Equal(8ul, items[0].ReplayGap!.OldestAvailableSequence);
|
||||
Assert.False(items[1].IsReplayGap);
|
||||
Assert.Equal(MxEventFamily.OnDataChange, items[1].Event.Family);
|
||||
Assert.Equal(3ul, server.Service.StreamEventsRequest?.AfterWorkerSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a genuine <c>PERMISSION_DENIED</c> status maps to the typed client
|
||||
/// exception rather than a bare RpcException.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PermissionDeniedStatus_MapsToAuthorizationException()
|
||||
{
|
||||
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(
|
||||
service => service.DenyInvoke = true);
|
||||
await using MxGatewayClient client = server.CreateClient(ApiKey);
|
||||
|
||||
MxGatewaySession session = await client.OpenSessionAsync(
|
||||
new OpenSessionRequest { ClientSessionName = "wire-test" });
|
||||
|
||||
await Assert.ThrowsAsync<MxGatewayAuthorizationException>(
|
||||
() => session.RegisterAsync("wire-test-client"));
|
||||
}
|
||||
|
||||
private static async Task<List<MxEvent>> CollectAsync(IAsyncEnumerable<MxEvent> stream)
|
||||
{
|
||||
List<MxEvent> events = [];
|
||||
await foreach (MxEvent gatewayEvent in stream)
|
||||
{
|
||||
events.Add(gatewayEvent);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts the real <c>mxaccess_gateway.v1.MxAccessGateway</c> service on a loopback
|
||||
/// Kestrel endpoint so client tests exercise genuine HTTP/2 framing, protobuf
|
||||
/// serialization, call metadata, and gRPC status propagation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the counterpart of <see cref="FakeGatewayTransport"/>: that fake replaces
|
||||
/// <c>IMxGatewayClientTransport</c>, so nothing below the client wrapper runs. This one
|
||||
/// replaces only the gateway's <em>behaviour</em> — every byte between the client and
|
||||
/// the service is the real wire format. Contract breaks that a transport fake cannot
|
||||
/// see (a field the client never decodes, metadata it does not actually send, a status
|
||||
/// code it maps differently once it arrives as a real <see cref="RpcException"/>) fail
|
||||
/// here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Plaintext h2c is used deliberately: TLS is covered by
|
||||
/// <c>MxGatewayClientTlsHandlerTests</c>, and h2c keeps the harness certificate-free so
|
||||
/// it runs identically on every CI host. See <c>docs/GatewayTesting.md</c>
|
||||
/// (Client Wire Tests) for the shared pattern and its Python counterpart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class WireFakeGatewayServer : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
|
||||
private WireFakeGatewayServer(WebApplication app, FakeGatewayService service, int port)
|
||||
{
|
||||
_app = app;
|
||||
Service = service;
|
||||
Endpoint = new Uri($"http://127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canned service backing the endpoint; tests read its recorded requests.
|
||||
/// </summary>
|
||||
public FakeGatewayService Service { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the h2c endpoint to point <see cref="MxGatewayClientOptions.Endpoint"/> at.
|
||||
/// </summary>
|
||||
public Uri Endpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts a server on an ephemeral loopback port.
|
||||
/// </summary>
|
||||
/// <param name="configure">Optional configuration of the canned service.</param>
|
||||
/// <returns>The started server.</returns>
|
||||
public static async Task<WireFakeGatewayServer> StartAsync(Action<FakeGatewayService>? configure = null)
|
||||
{
|
||||
FakeGatewayService service = new();
|
||||
configure?.Invoke(service);
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
// Port 0 lets the OS pick; HTTP/2 without TLS (h2c) is what the client's
|
||||
// plain http:// endpoint negotiates via RequestVersionExact.
|
||||
options.Listen(IPAddress.Loopback, 0, listen => listen.Protocols = HttpProtocols.Http2));
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton(service);
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.MapGrpcService<FakeGatewayService>();
|
||||
await app.StartAsync().ConfigureAwait(false);
|
||||
|
||||
return new WireFakeGatewayServer(app, service, ResolvePort(app));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a client bound to this server's endpoint.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">API key the client should present.</param>
|
||||
/// <returns>A client that talks to this server over h2c.</returns>
|
||||
public MxGatewayClient CreateClient(string apiKey) =>
|
||||
MxGatewayClient.Create(new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = Endpoint,
|
||||
ApiKey = apiKey,
|
||||
UseTls = false,
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync().ConfigureAwait(false);
|
||||
await _app.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int ResolvePort(WebApplication app)
|
||||
{
|
||||
IServerAddressesFeature? addresses = app.Services
|
||||
.GetRequiredService<IServer>()
|
||||
.Features
|
||||
.Get<IServerAddressesFeature>();
|
||||
string address = addresses?.Addresses.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Kestrel did not report a bound address.");
|
||||
return new Uri(address).Port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canned gateway answering the four session RPCs with gateway-shaped replies.
|
||||
/// </summary>
|
||||
internal sealed class FakeGatewayService : MxAccessGateway.MxAccessGatewayBase
|
||||
{
|
||||
/// <summary>The session id every reply carries.</summary>
|
||||
public const string SessionId = "wire-session-1";
|
||||
|
||||
/// <summary>The server handle the canned Register reply returns.</summary>
|
||||
public const int ServerHandle = 4242;
|
||||
|
||||
/// <summary>The item handle the canned data-change event carries.</summary>
|
||||
public const int ItemHandle = 77;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <c>authorization</c> header value observed per RPC name.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, string> AuthorizationByMethod { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether <c>Invoke</c> fails with
|
||||
/// <see cref="StatusCode.PermissionDenied"/> instead of replying.
|
||||
/// </summary>
|
||||
public bool DenyInvoke { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the replay-gap sentinel emitted at the head of the event stream.
|
||||
/// </summary>
|
||||
public ReplayGap? ReplayGap { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last <c>Invoke</c> request the client sent, as decoded from the wire.
|
||||
/// </summary>
|
||||
public MxCommandRequest? InvokeRequest { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last <c>StreamEvents</c> request the client sent.
|
||||
/// </summary>
|
||||
public StreamEventsRequest? StreamEventsRequest { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last <c>CloseSession</c> request the client sent.
|
||||
/// </summary>
|
||||
public CloseSessionRequest? CloseSessionRequest { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<OpenSessionReply> OpenSession(
|
||||
OpenSessionRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
Record(context);
|
||||
return Task.FromResult(new OpenSessionReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
BackendName = "fake-backend",
|
||||
WorkerProcessId = 1234,
|
||||
WorkerProtocolVersion = 1,
|
||||
GatewayProtocolVersion = 3,
|
||||
Capabilities = { "events", "invoke" },
|
||||
ProtocolStatus = Ok(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<MxCommandReply> Invoke(MxCommandRequest request, ServerCallContext context)
|
||||
{
|
||||
Record(context);
|
||||
InvokeRequest = request;
|
||||
|
||||
if (DenyInvoke)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied, "invoke scope required"));
|
||||
}
|
||||
|
||||
return Task.FromResult(new MxCommandReply
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = Ok(),
|
||||
Hresult = 0,
|
||||
Register = new RegisterReply { ServerHandle = ServerHandle },
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StreamEvents(
|
||||
StreamEventsRequest request,
|
||||
IServerStreamWriter<MxEvent> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
Record(context);
|
||||
StreamEventsRequest = request;
|
||||
|
||||
if (ReplayGap is not null)
|
||||
{
|
||||
// The sentinel shape the gateway emits: family unspecified, body unset,
|
||||
// only replay_gap populated.
|
||||
await responseStream.WriteAsync(new MxEvent
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
ReplayGap = ReplayGap,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await responseStream.WriteAsync(new MxEvent
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ServerHandle = ServerHandle,
|
||||
ItemHandle = ItemHandle,
|
||||
Value = new MxValue { Int32Value = 17 },
|
||||
Quality = 192,
|
||||
WorkerSequence = 9,
|
||||
OnDataChange = new OnDataChangeEvent(),
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<CloseSessionReply> CloseSession(
|
||||
CloseSessionRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
Record(context);
|
||||
CloseSessionRequest = request;
|
||||
return Task.FromResult(new CloseSessionReply
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
FinalState = SessionState.Closed,
|
||||
ProtocolStatus = Ok(),
|
||||
});
|
||||
}
|
||||
|
||||
private static ProtocolStatus Ok() => new() { Code = ProtocolStatusCode.Ok };
|
||||
|
||||
private void Record(ServerCallContext context)
|
||||
{
|
||||
string? authorization = context.RequestHeaders.GetValue("authorization");
|
||||
if (authorization is not null)
|
||||
{
|
||||
// context.Method is the fully-qualified "/package.Service/Method";
|
||||
// key on the bare method name so assertions stay readable.
|
||||
AuthorizationByMethod[context.Method[(context.Method.LastIndexOf('/') + 1)..]] =
|
||||
authorization;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -12,6 +12,15 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<!-- Wire tests only (WireFakeGatewayServer): hosts the real MxAccessGateway service
|
||||
on loopback Kestrel so the client is driven over genuine HTTP/2 + protobuf rather
|
||||
than a substituted transport. Version tracks the gateway server's Grpc.AspNetCore
|
||||
(src/ZB.MOM.WW.MxGateway.Server) and the client's Grpc.Net.Client, both 2.76.0. -->
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.76.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||
<Version>0.1.2</Version>
|
||||
<Version>0.2.0</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
|
||||
|
||||
@@ -471,7 +471,7 @@ go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key
|
||||
The module is resolved directly from the git repo — no package registry:
|
||||
|
||||
````bash
|
||||
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.1
|
||||
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.2.0
|
||||
````
|
||||
|
||||
Then import:
|
||||
@@ -494,11 +494,13 @@ Go modules in monorepo subdirectories use prefixed tags. To tag a release
|
||||
from this repo:
|
||||
|
||||
````bash
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.2.0 -Push
|
||||
````
|
||||
|
||||
The script validates semver, refuses to tag with uncommitted tracked
|
||||
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
|
||||
changes, verifies `clients/go/mxgateway/version.go`'s `ClientVersion`
|
||||
matches the requested tag version (failing the tag otherwise — CLI-21/CLI-39),
|
||||
creates an annotated tag `clients/go/v0.2.0`, and (with `-Push`)
|
||||
pushes it to origin.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Pinned generator baseline. The committed Go bindings stamp these plugin versions in their
|
||||
# headers (protoc-gen-go v1.36.11 / protoc-gen-go-grpc v1.6.2). Plugin-version drift rewrites
|
||||
# those header stamps, so a regeneration on an off-pin machine would churn the tree and make
|
||||
# check-codegen Check 4 false-fail (or mask real drift under churn). Assert the exact versions
|
||||
# so a regen is deterministic. protoc itself is warn-only (source_code_info is normalized out of
|
||||
# the committed bindings), matching publish-client-proto-inputs.ps1.
|
||||
$PinnedProtocGenGoVersion = 'protoc-gen-go v1.36.11'
|
||||
$PinnedProtocGenGoGrpcVersion = 'protoc-gen-go-grpc 1.6.2'
|
||||
$PinnedProtocVersion = 'libprotoc 34.1'
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos'
|
||||
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||
@@ -36,8 +46,25 @@ $wingetProtoc = if ($env:LOCALAPPDATA) {
|
||||
$goBin = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE 'go\bin' } elseif ($env:HOME) { Join-Path $env:HOME 'go/bin' } else { $null }
|
||||
|
||||
$protoc = Resolve-Tool -Names @('protoc', 'protoc.exe') -FallbackPaths @($wingetProtoc)
|
||||
$protocGenGo = Resolve-Tool -Names @('protoc-gen-go', 'protoc-gen-go.exe') -FallbackPaths @((if ($goBin) { Join-Path $goBin 'protoc-gen-go.exe' }), (if ($goBin) { Join-Path $goBin 'protoc-gen-go' }))
|
||||
$protocGenGoGrpc = Resolve-Tool -Names @('protoc-gen-go-grpc', 'protoc-gen-go-grpc.exe') -FallbackPaths @((if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc.exe' }), (if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc' }))
|
||||
$protocGenGo = Resolve-Tool -Names @('protoc-gen-go', 'protoc-gen-go.exe') -FallbackPaths @(($(if ($goBin) { Join-Path $goBin 'protoc-gen-go.exe' })), ($(if ($goBin) { Join-Path $goBin 'protoc-gen-go' })))
|
||||
$protocGenGoGrpc = Resolve-Tool -Names @('protoc-gen-go-grpc', 'protoc-gen-go-grpc.exe') -FallbackPaths @(($(if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc.exe' })), ($(if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc' })))
|
||||
|
||||
# Assert the pinned plugin versions before generating so Check 4 cannot false-fail (or mask drift)
|
||||
# on an off-pin machine. protoc is warn-only.
|
||||
$protocGenGoVersion = (& $protocGenGo --version 2>&1 | Out-String).Trim()
|
||||
if ($protocGenGoVersion -ne $PinnedProtocGenGoVersion) {
|
||||
throw "protoc-gen-go reports '$protocGenGoVersion', but regeneration is pinned to '$PinnedProtocGenGoVersion'. " +
|
||||
"Install the pin: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"
|
||||
}
|
||||
$protocGenGoGrpcVersion = (& $protocGenGoGrpc --version 2>&1 | Out-String).Trim()
|
||||
if ($protocGenGoGrpcVersion -ne $PinnedProtocGenGoGrpcVersion) {
|
||||
throw "protoc-gen-go-grpc reports '$protocGenGoGrpcVersion', but regeneration is pinned to '$PinnedProtocGenGoGrpcVersion'. " +
|
||||
"Install the pin: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2"
|
||||
}
|
||||
$protocVersion = (& $protoc --version 2>&1 | Out-String).Trim()
|
||||
if ($protocVersion -ne $PinnedProtocVersion) {
|
||||
Write-Warning "protoc reports '$protocVersion', pin is '$PinnedProtocVersion'. Descriptor comments are normalized out of the committed Go bindings, so patch drift is tolerated; keep CI on the pin."
|
||||
}
|
||||
|
||||
# protoc discovers the plugins on PATH; prepend the directories the resolved plugins live in.
|
||||
$env:Path = (Split-Path $protocGenGo -Parent) + [System.IO.Path]::PathSeparator + (Split-Path $protocGenGoGrpc -Parent) + [System.IO.Path]::PathSeparator + $env:Path
|
||||
|
||||
@@ -2553,6 +2553,9 @@ func (x *ActivateCommand) GetItemHandle() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
type WriteCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -2621,6 +2624,7 @@ func (x *WriteCommand) GetUserId() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Same statuses correlation as WriteCommand.
|
||||
type Write2Command struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -2697,6 +2701,9 @@ func (x *Write2Command) GetUserId() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
type WriteSecuredCommand struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -2775,6 +2782,9 @@ func (x *WriteSecuredCommand) GetValue() *MxValue {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
type WriteSecured2Command struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
|
||||
@@ -4575,8 +4585,21 @@ type MxCommandReply struct {
|
||||
// HRESULT captured from MXAccess or a COM exception. This remains separate
|
||||
// from gateway protocol status so MXAccess parity details are not hidden by
|
||||
// transport failures.
|
||||
Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
||||
ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"`
|
||||
Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
|
||||
ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"`
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// window can swap rows. The OnWriteComplete event still flows on the event
|
||||
// stream unchanged. Bulk write kinds and all non-write kinds leave this
|
||||
// field as before.
|
||||
Statuses []*MxStatusProxy `protobuf:"bytes,7,rep,name=statuses,proto3" json:"statuses,omitempty"`
|
||||
DiagnosticMessage string `protobuf:"bytes,8,opt,name=diagnostic_message,json=diagnosticMessage,proto3" json:"diagnostic_message,omitempty"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
@@ -5974,8 +5997,12 @@ func (x *WorkerInfoReply) GetMxaccessClsid() string {
|
||||
}
|
||||
|
||||
type DrainEventsReply struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Events []*MxEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The reply is bounded by both a server-side count cap and the negotiated
|
||||
// worker-frame byte cap; a reply may therefore carry fewer events than
|
||||
// `max_events` and fewer than are queued. Callers drain iteratively until an
|
||||
// empty reply.
|
||||
Events []*MxEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -6411,6 +6438,11 @@ type ReplayGap struct {
|
||||
// after_worker_sequence = oldest_available_sequence - 1 in the next
|
||||
// StreamEventsRequest, which will cause the server to replay starting at
|
||||
// oldest_available_sequence (the first retained event).
|
||||
// When nothing is retained (the replay ring is empty), this is the next sequence
|
||||
// that can be delivered — `highest observed + 1` — and the `oldest - 1` resume
|
||||
// formula remains valid: it resolves to the highest sequence already seen, so the
|
||||
// follow-up resume replays nothing, reports no gap, and every newer live event
|
||||
// passes. The interval evicted is unchanged.
|
||||
OldestAvailableSequence uint64 `protobuf:"varint,2,opt,name=oldest_available_sequence,json=oldestAvailableSequence,proto3" json:"oldest_available_sequence,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
||||
@@ -431,8 +431,17 @@ type GatewayHello struct {
|
||||
SupportedProtocolVersion uint32 `protobuf:"varint,1,opt,name=supported_protocol_version,json=supportedProtocolVersion,proto3" json:"supported_protocol_version,omitempty"`
|
||||
Nonce string `protobuf:"bytes,2,opt,name=nonce,proto3" json:"nonce,omitempty"`
|
||||
GatewayVersion string `protobuf:"bytes,3,opt,name=gateway_version,json=gatewayVersion,proto3" json:"gateway_version,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
|
||||
// configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
|
||||
// instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
// "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
// Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
// including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
// to fit rather than emit an oversized frame.
|
||||
MaxFrameBytes uint32 `protobuf:"varint,4,opt,name=max_frame_bytes,json=maxFrameBytes,proto3" json:"max_frame_bytes,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GatewayHello) Reset() {
|
||||
@@ -486,6 +495,13 @@ func (x *GatewayHello) GetGatewayVersion() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GatewayHello) GetMaxFrameBytes() uint32 {
|
||||
if x != nil {
|
||||
return x.MaxFrameBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type WorkerHello struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"`
|
||||
@@ -1109,11 +1125,12 @@ const file_mxaccess_worker_proto_rawDesc = "" +
|
||||
"\fworker_event\x18\x12 \x01(\v2\x1f.mxaccess_worker.v1.WorkerEventH\x00R\vworkerEvent\x12P\n" +
|
||||
"\x10worker_heartbeat\x18\x13 \x01(\v2#.mxaccess_worker.v1.WorkerHeartbeatH\x00R\x0fworkerHeartbeat\x12D\n" +
|
||||
"\fworker_fault\x18\x14 \x01(\v2\x1f.mxaccess_worker.v1.WorkerFaultH\x00R\vworkerFaultB\x06\n" +
|
||||
"\x04body\"\x8b\x01\n" +
|
||||
"\x04body\"\xb3\x01\n" +
|
||||
"\fGatewayHello\x12<\n" +
|
||||
"\x1asupported_protocol_version\x18\x01 \x01(\rR\x18supportedProtocolVersion\x12\x14\n" +
|
||||
"\x05nonce\x18\x02 \x01(\tR\x05nonce\x12'\n" +
|
||||
"\x0fgateway_version\x18\x03 \x01(\tR\x0egatewayVersion\"\xa1\x01\n" +
|
||||
"\x0fgateway_version\x18\x03 \x01(\tR\x0egatewayVersion\x12&\n" +
|
||||
"\x0fmax_frame_bytes\x18\x04 \x01(\rR\rmaxFrameBytes\"\xa1\x01\n" +
|
||||
"\vWorkerHello\x12)\n" +
|
||||
"\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x12\x14\n" +
|
||||
"\x05nonce\x18\x02 \x01(\tR\x05nonce\x12*\n" +
|
||||
|
||||
@@ -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.1.2"
|
||||
ClientVersion = "0.2.0"
|
||||
|
||||
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||
// in the shared .NET contracts.
|
||||
|
||||
@@ -465,7 +465,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.2'
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.2.1'
|
||||
}
|
||||
````
|
||||
|
||||
|
||||
@@ -13,7 +13,14 @@ ext {
|
||||
|
||||
subprojects {
|
||||
group = 'com.zb.mom.ww.mxgateway'
|
||||
version = '0.2.0'
|
||||
// 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
|
||||
// "Versioning" section of docs/ClientPackaging.md.
|
||||
version = '0.2.1'
|
||||
|
||||
pluginManager.withPlugin('java') {
|
||||
java {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3797,6 +3797,9 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
* "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
* Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
* including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
* to fit rather than emit an oversized frame.
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint32 max_frame_bytes = 4;</code>
|
||||
@@ -3941,6 +3944,9 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
* "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
* Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
* including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
* to fit rather than emit an oversized frame.
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint32 max_frame_bytes = 4;</code>
|
||||
@@ -4499,6 +4505,9 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
* "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
* Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
* including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
* to fit rather than emit an oversized frame.
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint32 max_frame_bytes = 4;</code>
|
||||
@@ -4515,6 +4524,9 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
* "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
* Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
* including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
* to fit rather than emit an oversized frame.
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint32 max_frame_bytes = 4;</code>
|
||||
@@ -4535,6 +4547,9 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
|
||||
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
* "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
* Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
* including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
* to fit rather than emit an oversized frame.
|
||||
* </pre>
|
||||
*
|
||||
* <code>uint32 max_frame_bytes = 4;</code>
|
||||
|
||||
+25
-4
@@ -5,6 +5,7 @@ import com.zb.mom.ww.mxgateway.client.DeployEventStream;
|
||||
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
|
||||
import com.zb.mom.ww.mxgateway.client.LazyBrowseNode;
|
||||
import com.zb.mom.ww.mxgateway.client.MxEventStream;
|
||||
import com.zb.mom.ww.mxgateway.client.MxEventStreamItem;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
|
||||
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
|
||||
@@ -59,6 +60,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.PingCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||
@@ -1654,11 +1656,30 @@ public final class MxGatewayCli implements Callable<Integer> {
|
||||
MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
|
||||
int count = 0;
|
||||
while (events.hasNext()) {
|
||||
MxEvent event = events.next();
|
||||
if (json) {
|
||||
client.out().println(protoJson(event));
|
||||
MxEventStreamItem item = events.nextItem();
|
||||
if (item.isReplayGap()) {
|
||||
// Render the ReplayGap sentinel as the typed cross-CLI row (NEXT-02,
|
||||
// mirroring the Go/Python/Rust/.NET CLIs) instead of the raw sentinel
|
||||
// event, whose text form printed "0 MX_EVENT_FAMILY_UNSPECIFIED".
|
||||
ReplayGap gap = item.replayGap();
|
||||
if (json) {
|
||||
client.out().printf(
|
||||
"{\"replayGap\":{\"requestedAfterSequence\":%s,\"oldestAvailableSequence\":%s}}%n",
|
||||
Long.toUnsignedString(gap.getRequestedAfterSequence()),
|
||||
Long.toUnsignedString(gap.getOldestAvailableSequence()));
|
||||
} else {
|
||||
client.out().printf(
|
||||
"REPLAY_GAP requested_after=%s oldest_available=%s%n",
|
||||
Long.toUnsignedString(gap.getRequestedAfterSequence()),
|
||||
Long.toUnsignedString(gap.getOldestAvailableSequence()));
|
||||
}
|
||||
} else {
|
||||
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
|
||||
MxEvent event = item.event();
|
||||
if (json) {
|
||||
client.out().println(protoJson(event));
|
||||
} else {
|
||||
client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
|
||||
}
|
||||
}
|
||||
count++;
|
||||
if (limit > 0 && count >= limit) {
|
||||
|
||||
+56
-2
@@ -43,6 +43,7 @@ import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||
@@ -59,7 +60,7 @@ final class MxGatewayCliTests {
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertEquals("", run.errors());
|
||||
assertTrue(run.output().contains("mxgateway-java 0.2.0"));
|
||||
assertTrue(run.output().contains("mxgateway-java 0.2.1"));
|
||||
assertTrue(run.output().contains("gatewayProtocolVersion=3"));
|
||||
assertTrue(run.output().contains("workerProtocolVersion=1"));
|
||||
}
|
||||
@@ -89,7 +90,7 @@ final class MxGatewayCliTests {
|
||||
CliRun run = execute(new FakeClientFactory(), "version", "--json");
|
||||
|
||||
assertEquals(0, run.exitCode());
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.2.0\""));
|
||||
assertTrue(run.output().contains("\"clientVersion\":\"0.2.1\""));
|
||||
assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
|
||||
}
|
||||
|
||||
@@ -902,6 +903,59 @@ final class MxGatewayCliTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamEventsRendersReplayGapAsTypedRow() {
|
||||
// NEXT-02: the ReplayGap sentinel must render as the typed cross-CLI
|
||||
// row (numeric cursors under a replayGap key in --json, a REPLAY_GAP
|
||||
// line in text mode), never as the raw sentinel event — text mode
|
||||
// used to print "0 MX_EVENT_FAMILY_UNSPECIFIED".
|
||||
MxEvent gap = MxEvent.newBuilder()
|
||||
.setReplayGap(ReplayGap.newBuilder()
|
||||
.setRequestedAfterSequence(7L)
|
||||
.setOldestAvailableSequence(42L)
|
||||
.build())
|
||||
.build();
|
||||
MxEvent dataChange = MxEvent.newBuilder()
|
||||
.setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
|
||||
.setSessionId("session-cli")
|
||||
.setWorkerSequence(43L)
|
||||
.build();
|
||||
|
||||
try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
|
||||
harness.setScriptedEvents(List.of(gap, dataChange));
|
||||
CliRun jsonRun = execute(
|
||||
new HarnessClientFactory(harness),
|
||||
"stream-events",
|
||||
"--session-id",
|
||||
"session-cli",
|
||||
"--json");
|
||||
|
||||
assertEquals(0, jsonRun.exitCode(), "errors:\n" + jsonRun.errors());
|
||||
String jsonOut = jsonRun.output();
|
||||
assertTrue(
|
||||
jsonOut.contains(
|
||||
"{\"replayGap\":{\"requestedAfterSequence\":7,\"oldestAvailableSequence\":42}}"),
|
||||
jsonOut);
|
||||
assertTrue(jsonOut.contains("\"family\":\"MX_EVENT_FAMILY_ON_DATA_CHANGE\""), jsonOut);
|
||||
assertFalse(jsonOut.contains("MX_EVENT_FAMILY_UNSPECIFIED"), jsonOut);
|
||||
}
|
||||
|
||||
try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
|
||||
harness.setScriptedEvents(List.of(gap, dataChange));
|
||||
CliRun textRun = execute(
|
||||
new HarnessClientFactory(harness),
|
||||
"stream-events",
|
||||
"--session-id",
|
||||
"session-cli");
|
||||
|
||||
assertEquals(0, textRun.exitCode(), "errors:\n" + textRun.errors());
|
||||
String textOut = textRun.output();
|
||||
assertTrue(textOut.contains("REPLAY_GAP requested_after=7 oldest_available=42"), textOut);
|
||||
assertFalse(textOut.contains("MX_EVENT_FAMILY_UNSPECIFIED"), textOut);
|
||||
assertTrue(textOut.contains("43 MX_EVENT_FAMILY_ON_DATA_CHANGE"), textOut);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- galaxy-discover / galaxy-watch over the in-process harness (Task 6) ----
|
||||
|
||||
@Test
|
||||
|
||||
@@ -63,10 +63,11 @@ protobuf {
|
||||
// or a plugin/protobuf version bump, silently drifts the committed output. checkGeneratedClean
|
||||
// fails when the regenerated tree differs from what is committed.
|
||||
//
|
||||
// Caveat (repo memory project_java_generated_churn): the protobuf gradle plugin also rewrites
|
||||
// MxaccessGateway.java with a spurious protobuf-runtime-version delta on every build even when no
|
||||
// .proto changed. CI reverts that one file (git checkout) before invoking this task; locally, do the
|
||||
// same when you did not touch a .proto. See docs/GatewayTesting.md "Continuous Integration".
|
||||
// The grpc/protobuf toolchain is pinned (build.gradle: grpcVersion / protobufVersion), so a
|
||||
// regeneration is byte-identical to the committed single-file aggregates modulo real .proto
|
||||
// changes — no spurious protobuf-runtime-version churn (IPC-24 verified this and deleted the old
|
||||
// unconditional CI churn-revert step, which masked message-level drift). Regenerate and commit
|
||||
// after any .proto change. See docs/GatewayTesting.md "Continuous Integration".
|
||||
tasks.register('checkGeneratedClean') {
|
||||
group = 'verification'
|
||||
description = 'Fails if the committed generated Java tree differs from a fresh regeneration.'
|
||||
@@ -83,9 +84,9 @@ tasks.register('checkGeneratedClean') {
|
||||
def dirty = stdout.toString().trim()
|
||||
if (!dirty.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"Generated Java is stale or churned:\n${dirty}\n" +
|
||||
"Regenerate and commit after a .proto change, or 'git checkout' the spurious " +
|
||||
"MxaccessGateway.java protobuf-version churn when no .proto changed.")
|
||||
"Generated Java is stale:\n${dirty}\n" +
|
||||
"Regenerate and commit the Java client after a .proto change " +
|
||||
"(gradle :zb-mom-ww-mxgateway-client:generateProto).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.0";
|
||||
private static final String CLIENT_VERSION = "0.2.1";
|
||||
|
||||
private MxGatewayClientVersion() {
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -47,6 +47,19 @@ The tests import the generated gateway and worker stubs, run fake async gateway
|
||||
stubs, verify API key metadata, exercise stream cancellation, load shared value
|
||||
and command fixtures, and check deterministic CLI output.
|
||||
|
||||
`tests/test_wire_fake_gateway.py` is the one suite that does **not** substitute a
|
||||
stub: it serves a canned `MxAccessGatewayServicer` from a real `grpc.aio` server
|
||||
on an ephemeral loopback port and drives the ordinary `GatewayClient` API against
|
||||
it. Only the gateway's behaviour is canned — the HTTP/2 framing, protobuf
|
||||
serialization, `authorization` metadata, and gRPC status codes are genuine, so it
|
||||
catches decode and metadata breaks a stub fake cannot see. No MXAccess, no worker,
|
||||
no TLS, so it runs in the default suite. See `docs/GatewayTesting.md`
|
||||
(Client Wire Tests) for the cross-client pattern.
|
||||
|
||||
```powershell
|
||||
python -m pytest tests/test_wire_fake_gateway.py
|
||||
```
|
||||
|
||||
## Packaging
|
||||
|
||||
Install the package in editable mode for local development:
|
||||
@@ -398,6 +411,15 @@ point: the `require_certificate_validation=True` keyword on
|
||||
`--require-certificate-validation` CLI flag. See
|
||||
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||
|
||||
Channel construction is split in two: `resolve_channel_security(options)` performs
|
||||
the blocking part (the trust-on-first-use certificate probe) and
|
||||
`create_channel(options, security=...)` builds the channel. The async `connect`
|
||||
classmethods run the first off the event loop and the second on it, because a
|
||||
`grpc.aio` channel binds to the event loop current on the constructing thread —
|
||||
building it inside `asyncio.to_thread` raises
|
||||
`RuntimeError: There is no current event loop in thread 'asyncio_N'`. Callers that
|
||||
build their own channel should keep `create_channel` on the loop thread.
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI emits deterministic JSON for automation:
|
||||
|
||||
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "zb-mom-ww-mxaccess-gateway-client"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
description = "Async Python client for MXAccess Gateway."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -12,7 +12,7 @@ from .auth import merge_metadata
|
||||
from .errors import 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
|
||||
from .options import ClientOptions, create_channel, resolve_channel_security
|
||||
|
||||
|
||||
class GatewayClient:
|
||||
@@ -58,9 +58,13 @@ class GatewayClient:
|
||||
if stub is not None:
|
||||
return cls(options=resolved, stub=stub)
|
||||
|
||||
# create_channel may perform a blocking TLS certificate probe (TOFU
|
||||
# default); run it off the event loop so connect never freezes the loop.
|
||||
channel = await asyncio.to_thread(create_channel, resolved)
|
||||
# Resolving security may perform a blocking TLS certificate probe (TOFU
|
||||
# default); run that off the event loop so connect never freezes it. The
|
||||
# channel itself must be built on the loop thread — a grpc.aio channel
|
||||
# binds to the loop current on the constructing thread, and a worker
|
||||
# thread has none.
|
||||
security = await asyncio.to_thread(resolve_channel_security, resolved)
|
||||
channel = create_channel(resolved, security=security)
|
||||
return cls(
|
||||
options=resolved,
|
||||
stub=pb_grpc.MxAccessGatewayStub(channel),
|
||||
|
||||
@@ -21,7 +21,12 @@ from .auth import merge_metadata
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
from .options import BrowseChildrenOptions, ClientOptions, create_channel
|
||||
from .options import (
|
||||
BrowseChildrenOptions,
|
||||
ClientOptions,
|
||||
create_channel,
|
||||
resolve_channel_security,
|
||||
)
|
||||
|
||||
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
|
||||
_BROWSE_CHILDREN_PAGE_SIZE = 500
|
||||
@@ -70,9 +75,13 @@ class GalaxyRepositoryClient:
|
||||
if stub is not None:
|
||||
return cls(options=resolved, stub=stub)
|
||||
|
||||
# create_channel may perform a blocking TLS certificate probe (TOFU
|
||||
# default); run it off the event loop so connect never freezes the loop.
|
||||
channel = await asyncio.to_thread(create_channel, resolved)
|
||||
# Resolving security may perform a blocking TLS certificate probe (TOFU
|
||||
# default); run that off the event loop so connect never freezes it. The
|
||||
# channel itself must be built on the loop thread — a grpc.aio channel
|
||||
# binds to the loop current on the constructing thread, and a worker
|
||||
# thread has none.
|
||||
security = await asyncio.to_thread(resolve_channel_security, resolved)
|
||||
channel = create_channel(resolved, security=security)
|
||||
return cls(
|
||||
options=resolved,
|
||||
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
|
||||
|
||||
@@ -27,7 +27,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
|
||||
import mxaccess_gateway_pb2 as mxaccess__gateway__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15mxaccess_worker.proto\x12\x12mxaccess_worker.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x16mxaccess_gateway.proto\"\x95\x06\n\x0eWorkerEnvelope\x12\x18\n\x10protocol_version\x18\x01 \x01(\r\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x10\n\x08sequence\x18\x03 \x01(\x04\x12\x16\n\x0e\x63orrelation_id\x18\x04 \x01(\t\x12\x39\n\rgateway_hello\x18\n \x01(\x0b\x32 .mxaccess_worker.v1.GatewayHelloH\x00\x12\x37\n\x0cworker_hello\x18\x0b \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerHelloH\x00\x12\x37\n\x0cworker_ready\x18\x0c \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerReadyH\x00\x12;\n\x0eworker_command\x18\r \x01(\x0b\x32!.mxaccess_worker.v1.WorkerCommandH\x00\x12\x46\n\x14worker_command_reply\x18\x0e \x01(\x0b\x32&.mxaccess_worker.v1.WorkerCommandReplyH\x00\x12\x39\n\rworker_cancel\x18\x0f \x01(\x0b\x32 .mxaccess_worker.v1.WorkerCancelH\x00\x12=\n\x0fworker_shutdown\x18\x10 \x01(\x0b\x32\".mxaccess_worker.v1.WorkerShutdownH\x00\x12\x44\n\x13worker_shutdown_ack\x18\x11 \x01(\x0b\x32%.mxaccess_worker.v1.WorkerShutdownAckH\x00\x12\x37\n\x0cworker_event\x18\x12 \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerEventH\x00\x12?\n\x10worker_heartbeat\x18\x13 \x01(\x0b\x32#.mxaccess_worker.v1.WorkerHeartbeatH\x00\x12\x37\n\x0cworker_fault\x18\x14 \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerFaultH\x00\x42\x06\n\x04\x62ody\"Z\n\x0cGatewayHello\x12\"\n\x1asupported_protocol_version\x18\x01 \x01(\r\x12\r\n\x05nonce\x18\x02 \x01(\t\x12\x17\n\x0fgateway_version\x18\x03 \x01(\t\"i\n\x0bWorkerHello\x12\x18\n\x10protocol_version\x18\x01 \x01(\r\x12\r\n\x05nonce\x18\x02 \x01(\t\x12\x19\n\x11worker_process_id\x18\x03 \x01(\x05\x12\x16\n\x0eworker_version\x18\x04 \x01(\t\"\x8e\x01\n\x0bWorkerReady\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12\x17\n\x0fmxaccess_progid\x18\x02 \x01(\t\x12\x16\n\x0emxaccess_clsid\x18\x03 \x01(\t\x12\x33\n\x0fready_timestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"w\n\rWorkerCommand\x12/\n\x07\x63ommand\x18\x01 \x01(\x0b\x32\x1e.mxaccess_gateway.v1.MxCommand\x12\x35\n\x11\x65nqueue_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x81\x01\n\x12WorkerCommandReply\x12\x32\n\x05reply\x18\x01 \x01(\x0b\x32#.mxaccess_gateway.v1.MxCommandReply\x12\x37\n\x13\x63ompleted_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1e\n\x0cWorkerCancel\x12\x0e\n\x06reason\x18\x01 \x01(\t\"Q\n\x0eWorkerShutdown\x12/\n\x0cgrace_period\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0e\n\x06reason\x18\x02 \x01(\t\"H\n\x11WorkerShutdownAck\x12\x33\n\x06status\x18\x01 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\":\n\x0bWorkerEvent\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxEvent\"\xa5\x02\n\x0fWorkerHeartbeat\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12.\n\x05state\x18\x02 \x01(\x0e\x32\x1f.mxaccess_worker.v1.WorkerState\x12?\n\x1blast_sta_activity_timestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1d\n\x15pending_command_count\x18\x04 \x01(\r\x12\"\n\x1aoutbound_event_queue_depth\x18\x05 \x01(\r\x12\x1b\n\x13last_event_sequence\x18\x06 \x01(\x04\x12&\n\x1e\x63urrent_command_correlation_id\x18\x07 \x01(\t\"\xf4\x01\n\x0bWorkerFault\x12\x39\n\x08\x63\x61tegory\x18\x01 \x01(\x0e\x32\'.mxaccess_worker.v1.WorkerFaultCategory\x12\x16\n\x0e\x63ommand_method\x18\x02 \x01(\t\x12\x14\n\x07hresult\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\x0e\x65xception_type\x18\x04 \x01(\t\x12\x1a\n\x12\x64iagnostic_message\x18\x05 \x01(\t\x12<\n\x0fprotocol_status\x18\x06 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatusB\n\n\x08_hresult*\x97\x02\n\x0bWorkerState\x12\x1c\n\x18WORKER_STATE_UNSPECIFIED\x10\x00\x12\x19\n\x15WORKER_STATE_STARTING\x10\x01\x12\x1c\n\x18WORKER_STATE_HANDSHAKING\x10\x02\x12!\n\x1dWORKER_STATE_INITIALIZING_STA\x10\x03\x12\x16\n\x12WORKER_STATE_READY\x10\x04\x12\"\n\x1eWORKER_STATE_EXECUTING_COMMAND\x10\x05\x12\x1e\n\x1aWORKER_STATE_SHUTTING_DOWN\x10\x06\x12\x18\n\x14WORKER_STATE_STOPPED\x10\x07\x12\x18\n\x14WORKER_STATE_FAULTED\x10\x08*\xc7\x04\n\x13WorkerFaultCategory\x12%\n!WORKER_FAULT_CATEGORY_UNSPECIFIED\x10\x00\x12+\n\'WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS\x10\x01\x12\x37\n3WORKER_FAULT_CATEGORY_GATEWAY_AUTHENTICATION_FAILED\x10\x02\x12+\n\'WORKER_FAULT_CATEGORY_PROTOCOL_MISMATCH\x10\x03\x12,\n(WORKER_FAULT_CATEGORY_PROTOCOL_VIOLATION\x10\x04\x12+\n\'WORKER_FAULT_CATEGORY_PIPE_DISCONNECTED\x10\x05\x12\x32\n.WORKER_FAULT_CATEGORY_MXACCESS_CREATION_FAILED\x10\x06\x12\x31\n-WORKER_FAULT_CATEGORY_MXACCESS_COMMAND_FAILED\x10\x07\x12:\n6WORKER_FAULT_CATEGORY_MXACCESS_EVENT_CONVERSION_FAILED\x10\x08\x12\"\n\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n\x12*\n&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\x0b\x42&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15mxaccess_worker.proto\x12\x12mxaccess_worker.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x16mxaccess_gateway.proto\"\x95\x06\n\x0eWorkerEnvelope\x12\x18\n\x10protocol_version\x18\x01 \x01(\r\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x10\n\x08sequence\x18\x03 \x01(\x04\x12\x16\n\x0e\x63orrelation_id\x18\x04 \x01(\t\x12\x39\n\rgateway_hello\x18\n \x01(\x0b\x32 .mxaccess_worker.v1.GatewayHelloH\x00\x12\x37\n\x0cworker_hello\x18\x0b \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerHelloH\x00\x12\x37\n\x0cworker_ready\x18\x0c \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerReadyH\x00\x12;\n\x0eworker_command\x18\r \x01(\x0b\x32!.mxaccess_worker.v1.WorkerCommandH\x00\x12\x46\n\x14worker_command_reply\x18\x0e \x01(\x0b\x32&.mxaccess_worker.v1.WorkerCommandReplyH\x00\x12\x39\n\rworker_cancel\x18\x0f \x01(\x0b\x32 .mxaccess_worker.v1.WorkerCancelH\x00\x12=\n\x0fworker_shutdown\x18\x10 \x01(\x0b\x32\".mxaccess_worker.v1.WorkerShutdownH\x00\x12\x44\n\x13worker_shutdown_ack\x18\x11 \x01(\x0b\x32%.mxaccess_worker.v1.WorkerShutdownAckH\x00\x12\x37\n\x0cworker_event\x18\x12 \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerEventH\x00\x12?\n\x10worker_heartbeat\x18\x13 \x01(\x0b\x32#.mxaccess_worker.v1.WorkerHeartbeatH\x00\x12\x37\n\x0cworker_fault\x18\x14 \x01(\x0b\x32\x1f.mxaccess_worker.v1.WorkerFaultH\x00\x42\x06\n\x04\x62ody\"s\n\x0cGatewayHello\x12\"\n\x1asupported_protocol_version\x18\x01 \x01(\r\x12\r\n\x05nonce\x18\x02 \x01(\t\x12\x17\n\x0fgateway_version\x18\x03 \x01(\t\x12\x17\n\x0fmax_frame_bytes\x18\x04 \x01(\r\"i\n\x0bWorkerHello\x12\x18\n\x10protocol_version\x18\x01 \x01(\r\x12\r\n\x05nonce\x18\x02 \x01(\t\x12\x19\n\x11worker_process_id\x18\x03 \x01(\x05\x12\x16\n\x0eworker_version\x18\x04 \x01(\t\"\x8e\x01\n\x0bWorkerReady\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12\x17\n\x0fmxaccess_progid\x18\x02 \x01(\t\x12\x16\n\x0emxaccess_clsid\x18\x03 \x01(\t\x12\x33\n\x0fready_timestamp\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"w\n\rWorkerCommand\x12/\n\x07\x63ommand\x18\x01 \x01(\x0b\x32\x1e.mxaccess_gateway.v1.MxCommand\x12\x35\n\x11\x65nqueue_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x81\x01\n\x12WorkerCommandReply\x12\x32\n\x05reply\x18\x01 \x01(\x0b\x32#.mxaccess_gateway.v1.MxCommandReply\x12\x37\n\x13\x63ompleted_timestamp\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1e\n\x0cWorkerCancel\x12\x0e\n\x06reason\x18\x01 \x01(\t\"Q\n\x0eWorkerShutdown\x12/\n\x0cgrace_period\x18\x01 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x0e\n\x06reason\x18\x02 \x01(\t\"H\n\x11WorkerShutdownAck\x12\x33\n\x06status\x18\x01 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatus\":\n\x0bWorkerEvent\x12+\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x1c.mxaccess_gateway.v1.MxEvent\"\xa5\x02\n\x0fWorkerHeartbeat\x12\x19\n\x11worker_process_id\x18\x01 \x01(\x05\x12.\n\x05state\x18\x02 \x01(\x0e\x32\x1f.mxaccess_worker.v1.WorkerState\x12?\n\x1blast_sta_activity_timestamp\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1d\n\x15pending_command_count\x18\x04 \x01(\r\x12\"\n\x1aoutbound_event_queue_depth\x18\x05 \x01(\r\x12\x1b\n\x13last_event_sequence\x18\x06 \x01(\x04\x12&\n\x1e\x63urrent_command_correlation_id\x18\x07 \x01(\t\"\xf4\x01\n\x0bWorkerFault\x12\x39\n\x08\x63\x61tegory\x18\x01 \x01(\x0e\x32\'.mxaccess_worker.v1.WorkerFaultCategory\x12\x16\n\x0e\x63ommand_method\x18\x02 \x01(\t\x12\x14\n\x07hresult\x18\x03 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\x0e\x65xception_type\x18\x04 \x01(\t\x12\x1a\n\x12\x64iagnostic_message\x18\x05 \x01(\t\x12<\n\x0fprotocol_status\x18\x06 \x01(\x0b\x32#.mxaccess_gateway.v1.ProtocolStatusB\n\n\x08_hresult*\x97\x02\n\x0bWorkerState\x12\x1c\n\x18WORKER_STATE_UNSPECIFIED\x10\x00\x12\x19\n\x15WORKER_STATE_STARTING\x10\x01\x12\x1c\n\x18WORKER_STATE_HANDSHAKING\x10\x02\x12!\n\x1dWORKER_STATE_INITIALIZING_STA\x10\x03\x12\x16\n\x12WORKER_STATE_READY\x10\x04\x12\"\n\x1eWORKER_STATE_EXECUTING_COMMAND\x10\x05\x12\x1e\n\x1aWORKER_STATE_SHUTTING_DOWN\x10\x06\x12\x18\n\x14WORKER_STATE_STOPPED\x10\x07\x12\x18\n\x14WORKER_STATE_FAULTED\x10\x08*\xc7\x04\n\x13WorkerFaultCategory\x12%\n!WORKER_FAULT_CATEGORY_UNSPECIFIED\x10\x00\x12+\n\'WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS\x10\x01\x12\x37\n3WORKER_FAULT_CATEGORY_GATEWAY_AUTHENTICATION_FAILED\x10\x02\x12+\n\'WORKER_FAULT_CATEGORY_PROTOCOL_MISMATCH\x10\x03\x12,\n(WORKER_FAULT_CATEGORY_PROTOCOL_VIOLATION\x10\x04\x12+\n\'WORKER_FAULT_CATEGORY_PIPE_DISCONNECTED\x10\x05\x12\x32\n.WORKER_FAULT_CATEGORY_MXACCESS_CREATION_FAILED\x10\x06\x12\x31\n-WORKER_FAULT_CATEGORY_MXACCESS_COMMAND_FAILED\x10\x07\x12:\n6WORKER_FAULT_CATEGORY_MXACCESS_EVENT_CONVERSION_FAILED\x10\x08\x12\"\n\x1eWORKER_FAULT_CATEGORY_STA_HUNG\x10\t\x12(\n$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW\x10\n\x12*\n&WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT\x10\x0b\x42&\xaa\x02#ZB.MOM.WW.MxGateway.Contracts.Protob\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
@@ -35,32 +35,32 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'mxaccess_worker_pb2', _glob
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['DESCRIPTOR']._loaded_options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'\252\002#ZB.MOM.WW.MxGateway.Contracts.Proto'
|
||||
_globals['_WORKERSTATE']._serialized_start=2316
|
||||
_globals['_WORKERSTATE']._serialized_end=2595
|
||||
_globals['_WORKERFAULTCATEGORY']._serialized_start=2598
|
||||
_globals['_WORKERFAULTCATEGORY']._serialized_end=3181
|
||||
_globals['_WORKERSTATE']._serialized_start=2341
|
||||
_globals['_WORKERSTATE']._serialized_end=2620
|
||||
_globals['_WORKERFAULTCATEGORY']._serialized_start=2623
|
||||
_globals['_WORKERFAULTCATEGORY']._serialized_end=3206
|
||||
_globals['_WORKERENVELOPE']._serialized_start=135
|
||||
_globals['_WORKERENVELOPE']._serialized_end=924
|
||||
_globals['_GATEWAYHELLO']._serialized_start=926
|
||||
_globals['_GATEWAYHELLO']._serialized_end=1016
|
||||
_globals['_WORKERHELLO']._serialized_start=1018
|
||||
_globals['_WORKERHELLO']._serialized_end=1123
|
||||
_globals['_WORKERREADY']._serialized_start=1126
|
||||
_globals['_WORKERREADY']._serialized_end=1268
|
||||
_globals['_WORKERCOMMAND']._serialized_start=1270
|
||||
_globals['_WORKERCOMMAND']._serialized_end=1389
|
||||
_globals['_WORKERCOMMANDREPLY']._serialized_start=1392
|
||||
_globals['_WORKERCOMMANDREPLY']._serialized_end=1521
|
||||
_globals['_WORKERCANCEL']._serialized_start=1523
|
||||
_globals['_WORKERCANCEL']._serialized_end=1553
|
||||
_globals['_WORKERSHUTDOWN']._serialized_start=1555
|
||||
_globals['_WORKERSHUTDOWN']._serialized_end=1636
|
||||
_globals['_WORKERSHUTDOWNACK']._serialized_start=1638
|
||||
_globals['_WORKERSHUTDOWNACK']._serialized_end=1710
|
||||
_globals['_WORKEREVENT']._serialized_start=1712
|
||||
_globals['_WORKEREVENT']._serialized_end=1770
|
||||
_globals['_WORKERHEARTBEAT']._serialized_start=1773
|
||||
_globals['_WORKERHEARTBEAT']._serialized_end=2066
|
||||
_globals['_WORKERFAULT']._serialized_start=2069
|
||||
_globals['_WORKERFAULT']._serialized_end=2313
|
||||
_globals['_GATEWAYHELLO']._serialized_end=1041
|
||||
_globals['_WORKERHELLO']._serialized_start=1043
|
||||
_globals['_WORKERHELLO']._serialized_end=1148
|
||||
_globals['_WORKERREADY']._serialized_start=1151
|
||||
_globals['_WORKERREADY']._serialized_end=1293
|
||||
_globals['_WORKERCOMMAND']._serialized_start=1295
|
||||
_globals['_WORKERCOMMAND']._serialized_end=1414
|
||||
_globals['_WORKERCOMMANDREPLY']._serialized_start=1417
|
||||
_globals['_WORKERCOMMANDREPLY']._serialized_end=1546
|
||||
_globals['_WORKERCANCEL']._serialized_start=1548
|
||||
_globals['_WORKERCANCEL']._serialized_end=1578
|
||||
_globals['_WORKERSHUTDOWN']._serialized_start=1580
|
||||
_globals['_WORKERSHUTDOWN']._serialized_end=1661
|
||||
_globals['_WORKERSHUTDOWNACK']._serialized_start=1663
|
||||
_globals['_WORKERSHUTDOWNACK']._serialized_end=1735
|
||||
_globals['_WORKEREVENT']._serialized_start=1737
|
||||
_globals['_WORKEREVENT']._serialized_end=1795
|
||||
_globals['_WORKERHEARTBEAT']._serialized_start=1798
|
||||
_globals['_WORKERHEARTBEAT']._serialized_end=2091
|
||||
_globals['_WORKERFAULT']._serialized_start=2094
|
||||
_globals['_WORKERFAULT']._serialized_end=2338
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -105,7 +105,72 @@ def _split_authority(endpoint: str) -> tuple[str, int]:
|
||||
return (host or "localhost", int(port))
|
||||
|
||||
|
||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
@dataclass(frozen=True)
|
||||
class ChannelSecurity:
|
||||
"""Transport security resolved for one channel.
|
||||
|
||||
`credentials` is `None` for a plaintext channel. `target_name_override` is
|
||||
the SNI/authority override the TOFU path needs, kept separate from the
|
||||
caller's explicit `server_name_override` so the caller always wins.
|
||||
"""
|
||||
|
||||
credentials: grpc.ChannelCredentials | None = None
|
||||
target_name_override: str | None = None
|
||||
|
||||
|
||||
def resolve_channel_security(options: ClientOptions) -> ChannelSecurity:
|
||||
"""Resolve transport security for `options`, running any blocking probe.
|
||||
|
||||
This is the only blocking part of channel construction: the TOFU path opens
|
||||
a real TCP+TLS socket to fetch the server's certificate. It is split out of
|
||||
`create_channel` because a `grpc.aio` channel binds to the event loop
|
||||
*current on the constructing thread*, so the channel itself must be built on
|
||||
the loop thread — building it inside `asyncio.to_thread` raises
|
||||
``RuntimeError: There is no current event loop in thread 'asyncio_N'``. The
|
||||
async `connect` classmethods therefore run this function off the loop and
|
||||
then call `create_channel` on it.
|
||||
"""
|
||||
|
||||
if options.plaintext:
|
||||
return ChannelSecurity()
|
||||
|
||||
if options.ca_file:
|
||||
root_certificates = Path(options.ca_file).read_bytes()
|
||||
return ChannelSecurity(
|
||||
credentials=grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
||||
)
|
||||
|
||||
if options.require_certificate_validation:
|
||||
return ChannelSecurity(credentials=grpc.ssl_channel_credentials())
|
||||
|
||||
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
|
||||
# server's certificate (unverified) and pin it for this channel (TOFU).
|
||||
# The probe opens a real blocking TCP+TLS socket, so it MUST be bounded —
|
||||
# a black-holed / firewall-drop host would otherwise hang on the OS default
|
||||
# connect timeout (minutes). Bound it by call_timeout (or a short fixed
|
||||
# fallback) so the dial fails fast as a transport error.
|
||||
host, port = _split_authority(options.endpoint)
|
||||
probe_timeout = options.call_timeout if options.call_timeout else _TOFU_PROBE_TIMEOUT_SECONDS
|
||||
try:
|
||||
presented = ssl.get_server_certificate((host, port), timeout=probe_timeout)
|
||||
except OSError as error:
|
||||
raise MxGatewayTransportError(
|
||||
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
|
||||
) from error
|
||||
# The gateway self-signed cert always carries a "localhost" SAN, so default
|
||||
# the SNI/target-name override to it when none was supplied, tolerating
|
||||
# dial-by-IP or hostname mismatch.
|
||||
return ChannelSecurity(
|
||||
credentials=grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii")),
|
||||
target_name_override="localhost",
|
||||
)
|
||||
|
||||
|
||||
def create_channel(
|
||||
options: ClientOptions,
|
||||
*,
|
||||
security: ChannelSecurity | None = None,
|
||||
) -> grpc.aio.Channel:
|
||||
"""Create a plaintext or TLS `grpc.aio` channel from client options.
|
||||
|
||||
The TLS default is lenient: grpc-python has no per-channel skip-verify, so
|
||||
@@ -113,48 +178,29 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
as the channel's only trust root (trust-on-first-use). Set
|
||||
`require_certificate_validation=True` to force system-trust verification, or
|
||||
pass `ca_file` to verify against a specific CA — both bypass the TOFU path.
|
||||
|
||||
Pass *security* to reuse a `ChannelSecurity` already resolved off the event
|
||||
loop by `resolve_channel_security`; omit it and this call resolves (and may
|
||||
block) inline. Must run on the thread owning the event loop the channel will
|
||||
be used from.
|
||||
"""
|
||||
|
||||
security = security if security is not None else resolve_channel_security(options)
|
||||
|
||||
channel_options: list[tuple[str, str | int]] = [
|
||||
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
||||
("grpc.max_send_message_length", options.max_grpc_message_bytes),
|
||||
]
|
||||
if options.server_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", options.server_name_override))
|
||||
elif security.target_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", security.target_name_override))
|
||||
|
||||
if options.plaintext:
|
||||
if security.credentials is None:
|
||||
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
||||
|
||||
if options.ca_file:
|
||||
root_certificates = Path(options.ca_file).read_bytes()
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
||||
elif options.require_certificate_validation:
|
||||
credentials = grpc.ssl_channel_credentials()
|
||||
else:
|
||||
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
|
||||
# server's certificate (unverified) and pin it for this channel (TOFU).
|
||||
# The probe opens a real blocking TCP+TLS socket, so it MUST be bounded —
|
||||
# a black-holed / firewall-drop host would otherwise hang on the OS default
|
||||
# connect timeout (minutes). Bound it by call_timeout (or a short fixed
|
||||
# fallback) so the dial fails fast as a transport error. The async
|
||||
# `connect` classmethods run this off the event loop (asyncio.to_thread).
|
||||
host, port = _split_authority(options.endpoint)
|
||||
probe_timeout = options.call_timeout if options.call_timeout else _TOFU_PROBE_TIMEOUT_SECONDS
|
||||
try:
|
||||
presented = ssl.get_server_certificate((host, port), timeout=probe_timeout)
|
||||
except OSError as error:
|
||||
raise MxGatewayTransportError(
|
||||
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
|
||||
) from error
|
||||
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
|
||||
# The gateway self-signed cert always carries a "localhost" SAN, so default
|
||||
# the SNI/target-name override to it when none was supplied, tolerating
|
||||
# dial-by-IP or hostname mismatch.
|
||||
if not options.server_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
|
||||
|
||||
return grpc.aio.secure_channel(
|
||||
options.endpoint,
|
||||
credentials,
|
||||
security.credentials,
|
||||
options=channel_options,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Package version information."""
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for the Python CLI."""
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
@@ -12,6 +14,21 @@ from zb_mom_ww_mxgateway_cli.commands import main
|
||||
_BATCH_EOR = "__MXGW_BATCH_EOR__"
|
||||
|
||||
|
||||
def test_version_matches_pyproject_toml() -> None:
|
||||
"""`__version__` must track `pyproject.toml`'s `[project].version`.
|
||||
|
||||
The existing `version` command tests only assert self-consistency against
|
||||
`__version__` (the two hardcoded literals could still drift from each
|
||||
other without either test catching it — the CLI-26 residual drift mode).
|
||||
This test pins `__version__` to the single source of truth instead.
|
||||
"""
|
||||
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
with pyproject_path.open("rb") as handle:
|
||||
pyproject = tomllib.load(handle)
|
||||
|
||||
assert __version__ == pyproject["project"]["version"]
|
||||
|
||||
|
||||
def test_require_certificate_validation_flag_flows_through_connect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -12,6 +12,7 @@ from zb_mom_ww_mxgateway import client as client_module
|
||||
from zb_mom_ww_mxgateway import galaxy as galaxy_module
|
||||
from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway.options import ChannelSecurity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -21,11 +22,12 @@ async def test_gateway_connect_forwards_require_certificate_validation(
|
||||
"""The connect convenience kwarg must reach ClientOptions (Client.Python-027)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
def fake_resolve(options: ClientOptions) -> ChannelSecurity:
|
||||
captured["options"] = options
|
||||
return object()
|
||||
return ChannelSecurity()
|
||||
|
||||
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(client_module, "resolve_channel_security", fake_resolve)
|
||||
monkeypatch.setattr(client_module, "create_channel", _stub_create_channel)
|
||||
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
|
||||
|
||||
await GatewayClient.connect(
|
||||
@@ -43,11 +45,12 @@ async def test_galaxy_connect_forwards_require_certificate_validation(
|
||||
"""GalaxyRepositoryClient.connect must thread the flag too (Client.Python-027)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
def fake_resolve(options: ClientOptions) -> ChannelSecurity:
|
||||
captured["options"] = options
|
||||
return object()
|
||||
return ChannelSecurity()
|
||||
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(galaxy_module, "resolve_channel_security", fake_resolve)
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", _stub_create_channel)
|
||||
monkeypatch.setattr(
|
||||
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
|
||||
)
|
||||
@@ -61,52 +64,67 @@ async def test_galaxy_connect_forwards_require_certificate_validation(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_connect_runs_create_channel_off_the_event_loop(
|
||||
async def test_gateway_connect_splits_probe_off_loop_and_channel_on_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""connect must run the blocking channel factory off the loop (Client.Python-028)."""
|
||||
ran_in_thread: dict[str, bool] = {}
|
||||
"""The blocking probe runs off the loop; the channel is built on it.
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
# If this runs on the event loop thread, get_running_loop() succeeds.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
ran_in_thread["off_loop"] = False
|
||||
except RuntimeError:
|
||||
ran_in_thread["off_loop"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
|
||||
Client.Python-028 required the blocking TOFU probe off the event loop. The
|
||||
channel itself must nonetheless be constructed *on* the loop thread: a
|
||||
``grpc.aio`` channel binds to the loop current on the constructing thread,
|
||||
and a ``to_thread`` worker has none, so building it off-loop raises
|
||||
``RuntimeError: There is no current event loop``. Assert both halves.
|
||||
"""
|
||||
where = _record_connect_threads(monkeypatch, client_module)
|
||||
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
|
||||
|
||||
await GatewayClient.connect(endpoint="gateway.example:5001")
|
||||
|
||||
assert ran_in_thread["off_loop"] is True
|
||||
assert where == {"resolve_off_loop": True, "create_on_loop": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_galaxy_connect_runs_create_channel_off_the_event_loop(
|
||||
async def test_galaxy_connect_splits_probe_off_loop_and_channel_on_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GalaxyRepositoryClient.connect must also run the probe off the loop (Client.Python-028)."""
|
||||
ran_in_thread: dict[str, bool] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
ran_in_thread["off_loop"] = False
|
||||
except RuntimeError:
|
||||
ran_in_thread["off_loop"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
|
||||
"""GalaxyRepositoryClient.connect splits the probe and the channel the same way."""
|
||||
where = _record_connect_threads(monkeypatch, galaxy_module)
|
||||
monkeypatch.setattr(
|
||||
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
|
||||
)
|
||||
|
||||
await GalaxyRepositoryClient.connect(endpoint="gateway.example:5001")
|
||||
|
||||
assert ran_in_thread["off_loop"] is True
|
||||
assert where == {"resolve_off_loop": True, "create_on_loop": True}
|
||||
|
||||
|
||||
def _stub_create_channel(options: ClientOptions, *, security: ChannelSecurity) -> object:
|
||||
return object()
|
||||
|
||||
|
||||
def _on_event_loop_thread() -> bool:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _record_connect_threads(monkeypatch: pytest.MonkeyPatch, module: Any) -> dict[str, bool]:
|
||||
"""Patch *module*'s channel helpers to record which thread each ran on."""
|
||||
where: dict[str, bool] = {}
|
||||
|
||||
def fake_resolve(options: ClientOptions) -> ChannelSecurity:
|
||||
where["resolve_off_loop"] = not _on_event_loop_thread()
|
||||
return ChannelSecurity()
|
||||
|
||||
def fake_create_channel(options: ClientOptions, *, security: ChannelSecurity) -> object:
|
||||
where["create_on_loop"] = _on_event_loop_thread()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(module, "resolve_channel_security", fake_resolve)
|
||||
monkeypatch.setattr(module, "create_channel", fake_create_channel)
|
||||
return where
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Wire-level tests: the Python client against a real localhost gRPC server.
|
||||
|
||||
Every other test in this suite substitutes a fake *stub* object for
|
||||
``pb_grpc.MxAccessGatewayStub``, so nothing between the client wrapper and the
|
||||
generated stub is exercised: no HTTP/2 framing, no protobuf serialization, no
|
||||
call metadata, no gRPC status translation. That leaves a class of contract break
|
||||
— a field the gateway populates but the client never decodes, metadata the
|
||||
client believes it sends but does not, a status code it maps differently once it
|
||||
arrives as a real ``grpc.RpcError`` — invisible to the default suite.
|
||||
|
||||
These tests close that gap by serving the real ``mxaccess_gateway.v1.MxAccessGateway``
|
||||
service from an in-process ``grpc.aio`` server bound to ``127.0.0.1:0`` and
|
||||
driving the ordinary public client API against it. The bytes on the wire are the
|
||||
real ones; only the gateway's *behavior* is canned. No MXAccess, no worker, no
|
||||
network beyond loopback, so this runs everywhere the normal suite runs.
|
||||
|
||||
See ``docs/GatewayTesting.md`` (Client Wire Tests) for the shared pattern and its
|
||||
counterpart in the .NET client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
|
||||
from zb_mom_ww_mxgateway.errors import MxGatewayAuthorizationError
|
||||
from zb_mom_ww_mxgateway.events import ReplayGap
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
||||
|
||||
API_KEY = "mxgw_wiretest_secret"
|
||||
SESSION_ID = "wire-session-1"
|
||||
SERVER_HANDLE = 4242
|
||||
ITEM_HANDLE = 77
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _ok() -> pb.ProtocolStatus:
|
||||
return pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK)
|
||||
|
||||
|
||||
class FakeGateway(pb_grpc.MxAccessGatewayServicer):
|
||||
"""Canned gateway serving the four session RPCs over a real transport.
|
||||
|
||||
Replies are shaped like the gateway's own: an OK ``ProtocolStatus``, the
|
||||
echoed session id, and the typed payload the client wrapper reads (for
|
||||
example ``RegisterReply.server_handle``). Set ``deny`` to make ``Invoke``
|
||||
abort with ``PERMISSION_DENIED`` so the client's gRPC-status mapping is
|
||||
exercised against a genuine ``grpc.RpcError`` rather than a hand-built one.
|
||||
"""
|
||||
|
||||
def __init__(self, *, deny: bool = False, replay_gap: pb.ReplayGap | None = None) -> None:
|
||||
self.deny = deny
|
||||
self.replay_gap = replay_gap
|
||||
self.endpoint = ""
|
||||
self.metadata_by_method: dict[str, str] = {}
|
||||
self.open_request: pb.OpenSessionRequest | None = None
|
||||
self.invoke_request: pb.MxCommandRequest | None = None
|
||||
self.stream_request: pb.StreamEventsRequest | None = None
|
||||
self.close_request: pb.CloseSessionRequest | None = None
|
||||
|
||||
def _record(self, method: str, context: grpc.aio.ServicerContext) -> None:
|
||||
for key, value in context.invocation_metadata() or ():
|
||||
if key == "authorization":
|
||||
self.metadata_by_method[method] = value
|
||||
|
||||
async def OpenSession( # noqa: N802 - generated gRPC method name
|
||||
self, request: pb.OpenSessionRequest, context: grpc.aio.ServicerContext
|
||||
) -> pb.OpenSessionReply:
|
||||
"""Answer ``OpenSession`` with a fully populated reply."""
|
||||
self._record("OpenSession", context)
|
||||
self.open_request = request
|
||||
return pb.OpenSessionReply(
|
||||
session_id=SESSION_ID,
|
||||
backend_name="fake-backend",
|
||||
worker_process_id=1234,
|
||||
worker_protocol_version=1,
|
||||
capabilities=["events", "invoke"],
|
||||
gateway_protocol_version=3,
|
||||
protocol_status=_ok(),
|
||||
)
|
||||
|
||||
async def Invoke( # noqa: N802 - generated gRPC method name
|
||||
self, request: pb.MxCommandRequest, context: grpc.aio.ServicerContext
|
||||
) -> pb.MxCommandReply:
|
||||
"""Answer ``Invoke`` with a Register reply, or deny when configured."""
|
||||
self._record("Invoke", context)
|
||||
self.invoke_request = request
|
||||
if self.deny:
|
||||
await context.abort(grpc.StatusCode.PERMISSION_DENIED, "invoke scope required")
|
||||
return pb.MxCommandReply(
|
||||
session_id=request.session_id,
|
||||
correlation_id=request.client_correlation_id,
|
||||
kind=request.command.kind,
|
||||
protocol_status=_ok(),
|
||||
hresult=0,
|
||||
register=pb.RegisterReply(server_handle=SERVER_HANDLE),
|
||||
)
|
||||
|
||||
async def StreamEvents( # noqa: N802 - generated gRPC method name
|
||||
self, request: pb.StreamEventsRequest, context: grpc.aio.ServicerContext
|
||||
) -> AsyncIterator[pb.MxEvent]:
|
||||
"""Stream an optional replay-gap sentinel followed by one data change."""
|
||||
self._record("StreamEvents", context)
|
||||
self.stream_request = request
|
||||
if self.replay_gap is not None:
|
||||
# The sentinel shape the gateway emits: family unspecified, body
|
||||
# unset, only replay_gap populated.
|
||||
yield pb.MxEvent(session_id=request.session_id, replay_gap=self.replay_gap)
|
||||
yield pb.MxEvent(
|
||||
session_id=request.session_id,
|
||||
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
||||
server_handle=SERVER_HANDLE,
|
||||
item_handle=ITEM_HANDLE,
|
||||
value=pb.MxValue(int32_value=17),
|
||||
quality=192,
|
||||
worker_sequence=9,
|
||||
on_data_change=pb.OnDataChangeEvent(),
|
||||
)
|
||||
|
||||
async def CloseSession( # noqa: N802 - generated gRPC method name
|
||||
self, request: pb.CloseSessionRequest, context: grpc.aio.ServicerContext
|
||||
) -> pb.CloseSessionReply:
|
||||
"""Answer ``CloseSession`` with a closed final state."""
|
||||
self._record("CloseSession", context)
|
||||
self.close_request = request
|
||||
return pb.CloseSessionReply(
|
||||
session_id=request.session_id,
|
||||
final_state=pb.SESSION_STATE_CLOSED,
|
||||
protocol_status=_ok(),
|
||||
)
|
||||
|
||||
|
||||
ServeGateway = Callable[..., Awaitable[FakeGateway]]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def serve_gateway() -> AsyncIterator[ServeGateway]:
|
||||
"""Yield a factory that serves a :class:`FakeGateway` on loopback.
|
||||
|
||||
Each call starts its own server on a free port and records it for teardown,
|
||||
so a test can serve a differently-configured gateway without a fixture per
|
||||
variant.
|
||||
"""
|
||||
servers: list[grpc.aio.Server] = []
|
||||
|
||||
async def _start(**kwargs: object) -> FakeGateway:
|
||||
fake = FakeGateway(**kwargs) # type: ignore[arg-type]
|
||||
server = grpc.aio.server()
|
||||
pb_grpc.add_MxAccessGatewayServicer_to_server(fake, server)
|
||||
port = _free_port()
|
||||
server.add_insecure_port(f"127.0.0.1:{port}")
|
||||
await server.start()
|
||||
servers.append(server)
|
||||
fake.endpoint = f"127.0.0.1:{port}"
|
||||
return fake
|
||||
|
||||
try:
|
||||
yield _start
|
||||
finally:
|
||||
for server in servers:
|
||||
await server.stop(grace=None)
|
||||
|
||||
|
||||
async def _connect(fake: FakeGateway) -> GatewayClient:
|
||||
return await GatewayClient.connect(
|
||||
ClientOptions(
|
||||
endpoint=fake.endpoint,
|
||||
api_key=API_KEY,
|
||||
plaintext=True,
|
||||
call_timeout=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_round_trip_decodes_real_wire_bytes(serve_gateway: ServeGateway) -> None:
|
||||
"""Open, invoke, stream, and close against a real server over loopback."""
|
||||
wire_gateway = await serve_gateway()
|
||||
client = await _connect(wire_gateway)
|
||||
try:
|
||||
session = await client.open_session(client_session_name="wire-test")
|
||||
assert session.session_id == SESSION_ID
|
||||
assert session.open_reply.backend_name == "fake-backend"
|
||||
assert list(session.open_reply.capabilities) == ["events", "invoke"]
|
||||
|
||||
server_handle = await session.register("wire-test-client")
|
||||
assert server_handle == SERVER_HANDLE
|
||||
assert wire_gateway.invoke_request is not None
|
||||
assert wire_gateway.invoke_request.command.kind == pb.MX_COMMAND_KIND_REGISTER
|
||||
assert wire_gateway.invoke_request.command.register.client_name == "wire-test-client"
|
||||
|
||||
events = [event async for event in session.stream_events()]
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert not isinstance(event, ReplayGap)
|
||||
assert event.family == pb.MX_EVENT_FAMILY_ON_DATA_CHANGE
|
||||
assert event.server_handle == SERVER_HANDLE
|
||||
assert event.item_handle == ITEM_HANDLE
|
||||
assert event.value.int32_value == 17
|
||||
assert event.quality == 192
|
||||
assert event.worker_sequence == 9
|
||||
assert event.HasField("on_data_change")
|
||||
|
||||
close_reply = await session.close()
|
||||
assert close_reply.final_state == pb.SESSION_STATE_CLOSED
|
||||
assert wire_gateway.close_request is not None
|
||||
assert wire_gateway.close_request.session_id == SESSION_ID
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_reaches_the_server_on_every_rpc(serve_gateway: ServeGateway) -> None:
|
||||
"""The bearer header is on the wire for unary and streaming calls alike.
|
||||
|
||||
Stub-substituting tests can only assert what the client *passes*; this
|
||||
asserts what the server *receives*, which is the property that matters.
|
||||
"""
|
||||
wire_gateway = await serve_gateway()
|
||||
client = await _connect(wire_gateway)
|
||||
try:
|
||||
session = await client.open_session(client_session_name="wire-test")
|
||||
await session.register("wire-test-client")
|
||||
async for _ in session.stream_events():
|
||||
break
|
||||
await session.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
expected = f"Bearer {API_KEY}"
|
||||
assert wire_gateway.metadata_by_method == {
|
||||
"OpenSession": expected,
|
||||
"Invoke": expected,
|
||||
"StreamEvents": expected,
|
||||
"CloseSession": expected,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replay_gap_sentinel_survives_the_wire(serve_gateway: ServeGateway) -> None:
|
||||
"""A resumed stream surfaces the gateway's sentinel as a typed ``ReplayGap``."""
|
||||
replay_gap_gateway = await serve_gateway(
|
||||
replay_gap=pb.ReplayGap(requested_after_sequence=3, oldest_available_sequence=8)
|
||||
)
|
||||
client = await _connect(replay_gap_gateway)
|
||||
try:
|
||||
session = await client.open_session(client_session_name="wire-test")
|
||||
items = [item async for item in session.stream_events(after_worker_sequence=3)]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
assert len(items) == 2
|
||||
gap = items[0]
|
||||
assert isinstance(gap, ReplayGap)
|
||||
assert gap.requested_after_sequence == 3
|
||||
assert gap.oldest_available_sequence == 8
|
||||
assert gap.resume_after_worker_sequence == 7
|
||||
assert not isinstance(items[1], ReplayGap)
|
||||
assert items[1].family == pb.MX_EVENT_FAMILY_ON_DATA_CHANGE
|
||||
|
||||
assert replay_gap_gateway.stream_request is not None
|
||||
assert replay_gap_gateway.stream_request.after_worker_sequence == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_denied_maps_to_authorization_error(
|
||||
serve_gateway: ServeGateway,
|
||||
) -> None:
|
||||
"""A real ``PERMISSION_DENIED`` status becomes the typed client error."""
|
||||
denying_gateway = await serve_gateway(deny=True)
|
||||
client = await _connect(denying_gateway)
|
||||
try:
|
||||
session = await client.open_session(client_session_name="wire-test")
|
||||
with pytest.raises(MxGatewayAuthorizationError):
|
||||
await session.register("wire-test-client")
|
||||
finally:
|
||||
await client.close()
|
||||
Generated
+2
-2
@@ -590,7 +590,7 @@ checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "mxgw-cli"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures-util",
|
||||
@@ -1490,7 +1490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
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.1.2"
|
||||
version = "0.2.0"
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
|
||||
@@ -436,5 +436,5 @@ Then add the dependency:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
zb-mom-ww-mxgateway-client = { version = "0.1.1", registry = "dohertj2-gitea" }
|
||||
zb-mom-ww-mxgateway-client = { version = "0.2.0", registry = "dohertj2-gitea" }
|
||||
```
|
||||
|
||||
@@ -241,6 +241,9 @@ message ActivateCommand {
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
message WriteCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -248,6 +251,7 @@ message WriteCommand {
|
||||
int32 user_id = 4;
|
||||
}
|
||||
|
||||
// Same statuses correlation as WriteCommand.
|
||||
message Write2Command {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -256,6 +260,9 @@ message Write2Command {
|
||||
int32 user_id = 5;
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
message WriteSecuredCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -266,6 +273,9 @@ message WriteSecuredCommand {
|
||||
MxValue value = 5;
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
message WriteSecured2Command {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -525,6 +535,19 @@ message MxCommandReply {
|
||||
// transport failures.
|
||||
optional int32 hresult = 5;
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// window can swap rows. The OnWriteComplete event still flows on the event
|
||||
// stream unchanged. Bulk write kinds and all non-write kinds leave this
|
||||
// field as before.
|
||||
repeated MxStatusProxy statuses = 7;
|
||||
string diagnostic_message = 8;
|
||||
|
||||
@@ -676,6 +699,10 @@ message WorkerInfoReply {
|
||||
}
|
||||
|
||||
message DrainEventsReply {
|
||||
// The reply is bounded by both a server-side count cap and the negotiated
|
||||
// worker-frame byte cap; a reply may therefore carry fewer events than
|
||||
// `max_events` and fewer than are queued. Callers drain iteratively until an
|
||||
// empty reply.
|
||||
repeated MxEvent events = 1;
|
||||
}
|
||||
|
||||
@@ -760,6 +787,11 @@ message ReplayGap {
|
||||
// after_worker_sequence = oldest_available_sequence - 1 in the next
|
||||
// StreamEventsRequest, which will cause the server to replay starting at
|
||||
// oldest_available_sequence (the first retained event).
|
||||
// When nothing is retained (the replay ring is empty), this is the next sequence
|
||||
// that can be delivered — `highest observed + 1` — and the `oldest - 1` resume
|
||||
// formula remains valid: it resolves to the highest sequence already seen, so the
|
||||
// follow-up resume replays nothing, reports no gap, and every newer live event
|
||||
// passes. The interval evicted is unchanged.
|
||||
uint64 oldest_available_sequence = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ message GatewayHello {
|
||||
// instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
// "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
// Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
// including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
// to fit rather than emit an oversized frame.
|
||||
uint32 max_frame_bytes = 4;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,85 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||
Use plaintext only for a local gateway. Use TLS when the gateway crosses a
|
||||
machine boundary or uses a production certificate.
|
||||
|
||||
## Versioning
|
||||
|
||||
Every client's version lives in its own manifest: `clients/rust/Cargo.toml`
|
||||
(`[package]` and `[workspace.package]`, both must match — `crates/mxgw-cli`
|
||||
inherits via `version.workspace = true`), `clients/python/pyproject.toml`
|
||||
(`[project].version`) and `clients/python/src/zb_mom_ww_mxgateway/version.py`
|
||||
(`__version__`, must match `pyproject.toml`), `clients/go/mxgateway/version.go`
|
||||
(`ClientVersion`), `clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj`
|
||||
(`<Version>`), and `clients/java/build.gradle` (`subprojects { version = ... }`,
|
||||
mirrored by the hand-maintained `MxGatewayClientVersion.CLIENT_VERSION`
|
||||
constant — the two have drifted before and there is no build-time link
|
||||
between them, so bump both together).
|
||||
|
||||
`src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
||||
(`<Version>`) is a fifth, easy-to-miss manifest: it is not itself a
|
||||
language client, but `Invoke-PackDotnet` in `scripts/pack-clients.ps1`
|
||||
packs and publishes it in lockstep with the .NET Client (both
|
||||
`ZB.MOM.WW.MxGateway.*` nupkgs go through the same `-Publish` loop), and
|
||||
Contracts and the .NET Client have always released at the same version.
|
||||
Bump Contracts' `<Version>` alongside the .NET Client's — leaving it behind
|
||||
means the next `-Publish` packs a stale Contracts version, the collision
|
||||
guard below correctly refuses to re-publish it, and the loop aborts
|
||||
mid-way with the Client possibly already pushed (nupkgs are enumerated
|
||||
alphabetically, and `Client` sorts before `Contracts`). This is distinct
|
||||
from `src/Directory.Build.props`'s repo-wide `<Version>` default, which
|
||||
stamps the Server/Worker/test assemblies and is not part of the published
|
||||
client package set — see the comment there.
|
||||
|
||||
**Bump the version before every publish, never after.** A Gitea package feed
|
||||
rejects re-uploading an existing name+version, and `scripts/pack-clients.ps1`
|
||||
enforces this before it ever attempts a push: each per-language `-Publish`
|
||||
step queries the Gitea package API
|
||||
(`GET /api/v1/packages/dohertj2/{type}/{name}/{version}`) for the version
|
||||
about to be published and aborts with a clear error if it already exists —
|
||||
the script never force-overwrites a published artifact. `scripts/tag-go-module.ps1`
|
||||
carries the equivalent guard for the Go module: it refuses to create a
|
||||
`clients/go/vX.Y.Z` tag unless `clients/go/mxgateway/version.go`'s
|
||||
`ClientVersion` already equals `X.Y.Z` (CLI-21/CLI-39), so a forgotten
|
||||
version bump fails the tag instead of shipping a mismatched module.
|
||||
|
||||
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
|
||||
*already-published* 0.1.2/0.1.1 while their public APIs kept changing
|
||||
underneath it (see `archreview/2026-07-12/remediation/50-clients.md` CLI-39).
|
||||
A code-review follow-up on the same branch caught that the initial CLI-39
|
||||
pass bumped the .NET Client but left `Contracts.csproj` at 0.1.2 — since
|
||||
both publish through the same `Invoke-PackDotnet` `-Publish` loop, that
|
||||
would have made the very next `.NET` publish abort on the new collision
|
||||
guard partway through (Client already pushed, Contracts refused as a
|
||||
re-publish of the already-published 0.1.2). Fixed in the same branch.
|
||||
Verified against the live Gitea package API at that time: `nuget` had
|
||||
`ZB.MOM.WW.MxGateway.Client` and `.Contracts` published through 0.1.2; `pypi` (`zb-mom-ww-mxaccess-gateway-client`)
|
||||
and `cargo` (`zb-mom-ww-mxgateway-client`) had only reached 0.1.1 despite their
|
||||
source pinning 0.1.2; **`maven`
|
||||
(`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client`) had already published
|
||||
0.2.0 on 2026-06-26** — before the CLI-37/38/40/41 conformance fixes changed
|
||||
the client's observable behavior (`category`-based status validation,
|
||||
`hresult < 0`, exact-secret redaction, typed malformed-reply errors). Reusing
|
||||
0.2.0 for the conformant Java build would have labeled two different APIs
|
||||
with the same coordinate, so **Java is the one exception: it shipped as
|
||||
0.2.1**, not 0.2.0. Operators publishing a future release must re-check the
|
||||
target version against the live registry before assuming any of these
|
||||
numbers are still unclaimed — the guards above do this automatically at
|
||||
publish time, but a version bump in the source is still a manual step per
|
||||
client.
|
||||
|
||||
On 2026-08-07 that release shipped. Published coordinates on
|
||||
`gitea.dohertylan.com`: `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.0** and
|
||||
`ZB.MOM.WW.MxGateway.Contracts` **0.2.0**, `pypi`
|
||||
`zb-mom-ww-mxaccess-gateway-client` **0.2.0**, `cargo`
|
||||
`zb-mom-ww-mxgateway-client` **0.2.0**, `maven`
|
||||
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.1** (the Java
|
||||
exception described above). Go publishes no artifact — it ships as the module
|
||||
tag `clients/go/v0.2.0`, created at commit `a346d51`. Each coordinate was
|
||||
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.
|
||||
|
||||
## .NET
|
||||
|
||||
The .NET client uses .NET 10 and references
|
||||
@@ -133,6 +212,12 @@ a `cargo package` that cannot build from the vendored tree alone would mean
|
||||
the vendored copies are stale, and verification is what catches that before
|
||||
publish.
|
||||
|
||||
Publishing to the `dohertj2-gitea` alternative registry reads the token from
|
||||
`CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN`, and that variable must hold
|
||||
`Bearer <token>` — cargo sends the value as the `Authorization` header
|
||||
verbatim and Gitea's cargo registry rejects a bare token with `401`, unlike
|
||||
the other feeds, which authenticate with a username/token basic-auth pair.
|
||||
|
||||
Regenerate and compile Rust bindings:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -74,10 +74,12 @@ protoc-version encoding drift and does not false-fail across protoc releases
|
||||
(it warns, rather than fails, when protoc is off the pin).
|
||||
|
||||
The gateway test project carries an independent, protoc-free freshness guard:
|
||||
`ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField` reflects
|
||||
over the in-process contract descriptors and fails if any contract message or
|
||||
field is missing from the committed protoset. This is the primary CI gate for
|
||||
descriptor staleness; a red test means "regenerate and commit the protoset."
|
||||
`ClientProtoInputTests.Descriptor_ContainsEveryContractSymbol` reflects over the
|
||||
in-process contract descriptors — `mxaccess_gateway.proto`, `mxaccess_worker.proto`,
|
||||
and `galaxy_repository.proto` — and fails if any contract message, field, enum,
|
||||
enum value, service, or method is missing from the committed protoset. This is
|
||||
the primary CI gate for descriptor staleness; a red test means "regenerate and
|
||||
commit the protoset."
|
||||
|
||||
### Pinned generator versions
|
||||
|
||||
@@ -88,15 +90,23 @@ scripts assert the pin and resolve tools from `PATH`:
|
||||
| Generator | Pinned version | Guard |
|
||||
|-----------|----------------|-------|
|
||||
| protoc (descriptor set) | 34.1 | version assertion in `scripts/publish-client-proto-inputs.ps1` |
|
||||
| `Grpc.Tools` (C# `Generated/`) | 2.80.0 (contracts csproj) | `scripts/check-codegen.ps1` git-diff of `Generated/` |
|
||||
| `grpcio-tools` (Python) | 1.80.0 (protobuf runtime 6.31.1) | version assertion in `clients/python/generate-proto.ps1` |
|
||||
| protobuf / grpc-java (Java) | `protobufVersion` / `grpcVersion` in `clients/java/build.gradle` | `checkGeneratedClean` gradle task |
|
||||
| `Grpc.Tools` (C# `Generated/`) | 2.80.0 (contracts csproj) | `scripts/check-codegen.ps1` git-diff of `Generated/` (Check 2) |
|
||||
| `protoc-gen-go` (Go) | v1.36.11 | version assertion in `clients/go/generate-proto.ps1`; `check-codegen.ps1` Check 4 |
|
||||
| `protoc-gen-go-grpc` (Go) | 1.6.2 | version assertion in `clients/go/generate-proto.ps1`; `check-codegen.ps1` Check 4 |
|
||||
| `grpcio-tools` (Python) | 1.80.0 (protobuf runtime 6.31.1) | version assertion in `clients/python/generate-proto.ps1`; `check-codegen.ps1` Check 4 |
|
||||
| protobuf / grpc-java (Java) | `protobufVersion` / `grpcVersion` in `clients/java/build.gradle` | CI `git diff --exit-code` over `clients/java/src/main/generated` after `gradle test` (the `checkGeneratedClean` gradle task is the equivalent local check) |
|
||||
|
||||
A newer `grpcio-tools` stamps a `GRPC_GENERATED_VERSION` above the pinned grpcio
|
||||
runtime and breaks Python `pytest`; the Java protobuf plugin rewrites
|
||||
`MxaccessGateway.java` with spurious protobuf-runtime-version churn on every build
|
||||
(revert that one file when no `.proto` changed — see
|
||||
[Gateway Testing](./GatewayTesting.md) "Continuous Integration").
|
||||
runtime and breaks Python `pytest`, so the Python and Go scripts assert their
|
||||
generator pins before regenerating. Under the pinned grpc/protobuf toolchain the
|
||||
Java protobuf plugin regenerates byte-identical output (modulo real `.proto`
|
||||
changes), so CI enforces Java freshness with a direct `git diff --exit-code -- clients/java/src/main/generated`
|
||||
step after `gradle test` (which transitively regenerates via `generateProto`); the
|
||||
`checkGeneratedClean` gradle task is the equivalent check for local/manual use. The
|
||||
old unconditional churn-revert CI step (which masked message-level drift in the
|
||||
single-file Java aggregates) was deleted (IPC-24). Go and Python committed
|
||||
bindings are guarded by `check-codegen.ps1` **Check 4**, which regenerates both
|
||||
and fails on any diff.
|
||||
|
||||
## Output Directories
|
||||
|
||||
|
||||
+10
-5
@@ -114,7 +114,11 @@ dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csp
|
||||
`scripts/check-codegen.ps1` enforces this in CI (it force-regenerates and fails on any
|
||||
`git diff` against the committed `Generated/`) — that regeneration diff in the `portable`
|
||||
job is the primary guard. The SSH-driven `windows-x86` job's net48 worker build is the
|
||||
secondary guard (a stale `Generated/` also breaks the x86 build with `CS0246`).
|
||||
secondary guard (a stale `Generated/` also breaks the x86 build with `CS0246`). The same
|
||||
script runs four checks in total: the committed client descriptor set (Check 1), the C#
|
||||
`Generated/` (Check 2), the Rust vendored protos (Check 3), and the Go/Python client bindings
|
||||
(Check 4) each regenerate and fail on any diff. See
|
||||
[Client Proto Generation](./ClientProtoGeneration.md) for the pinned generator versions.
|
||||
|
||||
Client generation inputs are published through
|
||||
`clients/proto/proto-inputs.json` and the descriptor set under
|
||||
@@ -152,10 +156,11 @@ pwsh -File scripts/publish-client-proto-inputs.ps1
|
||||
|
||||
Freshness is guarded two ways so a skipped regeneration cannot ship silently:
|
||||
|
||||
- `ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField` (gateway test project)
|
||||
reflects over the in-process contract descriptors and fails if any message or field is missing
|
||||
from the committed protoset. It is semantic (symbol presence), needs no protoc, and runs in the
|
||||
Linux CI. A red test means "regenerate and commit the protoset."
|
||||
- `ClientProtoInputTests.Descriptor_ContainsEveryContractSymbol` (gateway test project)
|
||||
reflects over the in-process contract descriptors — including `galaxy_repository.proto` — and
|
||||
fails if any message, field, enum, enum value, service, or method is missing from the committed
|
||||
protoset. It is semantic (symbol presence), needs no protoc, and runs in the Linux CI. A red
|
||||
test means "regenerate and commit the protoset."
|
||||
- `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` rebuilds the descriptor and compares
|
||||
it to the committed one. The comparison normalizes both sides through the same protoc with
|
||||
`source_code_info` stripped, so it does not false-fail across protoc releases.
|
||||
|
||||
@@ -40,27 +40,24 @@ reports the next deliverable sequence rather than `0` (see [Sessions](Sessions.m
|
||||
The default smoke sequence opens a fresh stream (no cursor) and does not exercise
|
||||
the gap path; a resume-with-gap fixture case is tracked separately (TST-24).
|
||||
|
||||
The CLIs differ in how they *print* that library-level signal. Three of them consume
|
||||
the typed gap and emit a dedicated row rather than a degenerate event row; the other
|
||||
two hand the raw sentinel `MxEvent` straight to the formatter, so they print the
|
||||
sentinel itself, whose `replayGap` field carries the same cursors:
|
||||
All five CLIs consume the typed gap and emit a dedicated row rather than a
|
||||
degenerate event row (the .NET and Java halves were the last to convert — NEXT-02):
|
||||
|
||||
| CLI | Text mode | JSON mode |
|
||||
|-----|-----------|-----------|
|
||||
| `mxgw-rs` (Rust, canonical) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` as one entry of the `events` array |
|
||||
| `mxgw-go` (Go) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | one `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` line, counted toward `-limit` like any other row |
|
||||
| `mxgw-py` (Python) | same JSON dump as `--json` | `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` as one entry of the `events` array |
|
||||
| `mxgw-dotnet` (.NET) | the raw sentinel `MxEvent` as protobuf JSON, including its `replayGap` field | same, as one entry of the `events` array |
|
||||
| `mxgw-java` (Java) | the sentinel's `worker_sequence` and `family` (`0 MX_EVENT_FAMILY_UNSPECIFIED`) | the raw sentinel `MxEvent` as protobuf JSON, including its `replayGap` field |
|
||||
| `mxgw-dotnet` (.NET) | one `{"replayGap": {...}}` line (its "text" mode is JSON-per-line) | the same row — per line with `--jsonl`, as one entry of the `events` array with `--json` |
|
||||
| `mxgw-java` (Java) | `REPLAY_GAP requested_after=<n> oldest_available=<n>` | one `{"replayGap": {"requestedAfterSequence": <n>, "oldestAvailableSequence": <n>}}` line |
|
||||
|
||||
Rust, Go, and Python emit the same two key names and, deliberately, the same JSON
|
||||
value **types**: the cursors are JSON numbers (`7`), not strings. That is why the
|
||||
Go CLI types the row by hand instead of marshalling `ReplayGap` with `protojson` —
|
||||
the proto3 JSON mapping renders 64-bit integers as strings (`"7"`), which is also
|
||||
why the .NET and Java rows, which pass the sentinel through a protobuf JSON
|
||||
formatter, carry **quoted** cursors. A matrix runner must therefore compare parsed
|
||||
values, not raw bytes, and must not assume the same value type across all five
|
||||
CLIs.
|
||||
All five emit the same two key names and, deliberately, the same JSON value
|
||||
**types**: the cursors are JSON numbers (`7`), not strings. That is why every
|
||||
CLI types the row by hand instead of marshalling `ReplayGap` through its
|
||||
protobuf JSON formatter — the proto3 JSON mapping renders 64-bit integers as
|
||||
strings (`"7"`). Normal event rows still come from the protobuf formatters, so
|
||||
a matrix runner must still compare parsed values, not raw bytes, when it mixes
|
||||
gap rows with event rows.
|
||||
|
||||
Two further formatting differences among the three canonical CLIs, none of them
|
||||
semantic: Python sorts object keys and uses `", "` / `": "` separators
|
||||
|
||||
@@ -534,6 +534,53 @@ against the live MXAccess attribute set.
|
||||
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
|
||||
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)
|
||||
|
||||
## Write Completion Correlation
|
||||
|
||||
MXAccess writes are fire-and-forget: the toolkit call returns before the
|
||||
Galaxy commit, and the per-item outcome only exists in the later
|
||||
`OnWriteComplete` COM callback. The original unary write reply therefore
|
||||
proved worker-side command acceptance only, forcing consumers (OtOpcUa's
|
||||
GalaxyDriver) to report every write as provisionally good.
|
||||
|
||||
For the unary write kinds (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`)
|
||||
the worker now holds the unary reply for a
|
||||
bounded window (`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default
|
||||
1.5 s, `0` disables; conveyed to the worker via
|
||||
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS`) and copies the matching
|
||||
callback's status rows onto `MxCommandReply.statuses`. Key choices, argued in
|
||||
[the design doc](./plans/2026-08-09-write-completion-correlation-design.md):
|
||||
|
||||
- **Pump-wait on the STA, not a parked reply.** The executor holds the STA
|
||||
thread but pumps Windows messages each poll — the shipped ReadBulk pattern —
|
||||
because commands serialize per session anyway, so freeing the STA during the
|
||||
wait buys nothing and a parked reply would change the dispatcher/pipe
|
||||
contracts.
|
||||
- **Version baseline before the COM call** closes the fast-completion edge: a
|
||||
callback that dispatches while `WriteSecured` is still on the stack still
|
||||
correlates.
|
||||
- **Timeout returns today's shape** (protocol OK, empty statuses):
|
||||
unconfirmed is honest; a synthesized failure row would trigger consumer-side
|
||||
write-revert logic on slow-but-successful commits. The 1.5 s default stays
|
||||
inside OtOpcUa's 2 s Tier A write-resilience budget.
|
||||
- **Parity preserved.** `protocol_status`/`hresult` keep describing
|
||||
acceptance; the MXAccess outcome (success or failure) rides only in
|
||||
`statuses[0]`; the `OnWriteComplete` event still streams unchanged (nothing
|
||||
swallowed, nothing synthesized).
|
||||
- **Scope: all four unary write kinds; bulk writes stay fire-and-forget.**
|
||||
The first cut correlated `WriteSecured`/`WriteSecured2` only, but OtOpcUa's
|
||||
dominant FreeAccess write path goes out as plain `Write` (2026-08-09 live
|
||||
verification, 06/S-1) — a refused plain write was invisible on the reply.
|
||||
Plain `Write`/`Write2` now correlate identically. Bulk writes keep
|
||||
fire-and-forget replies: waiting per entry would add a device round-trip per
|
||||
item to high-rate supervisory loops.
|
||||
- **Best-effort correlation.** The callback carries only
|
||||
`(hItem, statuses)` — no transaction id — so concurrent writes to the same
|
||||
item within the window can swap rows; benign for the serialized single-write
|
||||
consumer contract.
|
||||
- **Client cancellation needs no special path**: a caller abandoning the RPC
|
||||
mid-wait leaves the worker to finish its bounded wait and reply; the gateway
|
||||
discards the reply, the session is never faulted.
|
||||
|
||||
## Later Revisit Items
|
||||
|
||||
These are explicit post-v1 revisit items, not open blockers:
|
||||
|
||||
@@ -219,6 +219,7 @@ The order matters: putting the logging scope first ensures that authentication f
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Identifying A Deployed Build](./runbooks/IdentifyingADeployedBuild.md) — mapping a running binary back to a commit, and why the `InformationalVersion` stamp cannot be trusted on Windows builds from 2026-07-09 to 2026-08-10
|
||||
- [Sessions](./Sessions.md)
|
||||
- [gRPC](./Grpc.md)
|
||||
- [Authentication](./Authentication.md)
|
||||
|
||||
@@ -114,6 +114,7 @@ launch CWD (SEC-01, SEC-33).
|
||||
| `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. |
|
||||
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
|
||||
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
|
||||
| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. |
|
||||
| `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. |
|
||||
| `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. |
|
||||
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
|
||||
@@ -249,7 +250,7 @@ dev/test GLAuth posture (`glauth.md`), not a production posture.
|
||||
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
||||
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
||||
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **No longer a committed plaintext value:** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is now blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed the value once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. An operator may instead override it directly with the env var `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) — a plain literal there is used as-is and the secret lookup is skipped. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **Never a committed plaintext value (SEC-36):** the shared GLAuth bind credential is supplied out-of-band through one of three channels, all binding to this key. **(1) Encrypted secrets store (shipped default):** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed it once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. **(2) Deployed hosts — env var:** override directly with `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) in the NSSM service environment — a plain literal there is used as-is and the store lookup is skipped. **(3) Dev boxes — user-secrets:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>` (the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`; user-secrets load automatically in the Development environment and live under the user profile, outside the tree). The value comes from the GLAuth source of truth `scadaproj/infra/glauth/`, never from a repo file. **Rotation:** because the credential was historically committed, it was rotated in `scadaproj/infra/glauth/` (and the shared GLAuth on `10.100.0.35` redeployed) on 2026-08-07 (SEC-36) — see `docs/runbooks/SEC-36-ldap-credential-rotation.md` for the cutover procedure used, and for future rotations. A blank/unresolved value fails startup validation with a message naming the two supported channels. |
|
||||
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
||||
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
||||
@@ -273,6 +274,14 @@ staging rig, e.g. one pointed at the plaintext shared GLAuth). A production-like
|
||||
deployment must therefore run with the literal `Production` environment name for
|
||||
the hard-stops to apply.
|
||||
|
||||
`windev` (`10.100.0.48`) is deliberately labelled `Staging` (its NSSM
|
||||
`DOTNET_ENVIRONMENT` entry, set 2026-08-07) rather than left at the `Production`
|
||||
default. It is the permissive rig the parenthesis above describes: it runs
|
||||
`Dashboard:DisableLogin=true` and binds the shared GLAuth, which offers no TLS,
|
||||
so a `Production` label would contradict its own configuration and both
|
||||
hard-stops would refuse the boot. Label a host `Production` only when its
|
||||
configuration can satisfy them.
|
||||
|
||||
## Secrets Master Key
|
||||
|
||||
`${secret:...}` tokens in configuration — currently just
|
||||
@@ -654,7 +663,7 @@ See each client README for the as-built behavior.
|
||||
|
||||
Transport security here applies only to the public gRPC channel. The
|
||||
gateway↔worker link is a per-session **named pipe**
|
||||
(`mxaccess-gateway-{gatewayPid}-{sessionId}`), not a network socket. It is not
|
||||
(`mxgw-{gatewayPid}-{sessionUid}`), not a network socket. It is not
|
||||
TLS-encrypted and does not need to be: it never leaves the local Windows host and
|
||||
is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md).
|
||||
|
||||
|
||||
@@ -418,9 +418,14 @@ The gateway creates the pipe server before launching the worker.
|
||||
Pipe name:
|
||||
|
||||
```text
|
||||
mxaccess-gateway-{gatewayProcessId}-{sessionId}
|
||||
mxgw-{gatewayProcessId}-{sessionUid}
|
||||
```
|
||||
|
||||
`sessionUid` is the session id's guid hex without the `session-` prefix. The
|
||||
short form keeps the Unix-domain-socket path .NET uses for named pipes on
|
||||
macOS/Linux (`$TMPDIR/CoreFxPipe_{name}`) inside the 104-byte macOS `sun_path`
|
||||
limit under the default per-user `TMPDIR`.
|
||||
|
||||
Message framing:
|
||||
|
||||
```text
|
||||
|
||||
+251
-27
@@ -82,7 +82,17 @@ fake-worker tests cannot validate:
|
||||
when the rig does not drive sample-bearing buffered batches on demand.
|
||||
|
||||
All eight tests are gated by the same `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`
|
||||
opt-in variable.
|
||||
opt-in variable. Opt-in does not mean unscheduled: the `nightly-windev` job runs
|
||||
this suite on windev every night at 06:00 UTC and files a Gitea issue when it goes
|
||||
red (see [Continuous Integration](#continuous-integration)), so the smoke no longer
|
||||
depends on someone remembering to set the variable.
|
||||
|
||||
Known coverage gap: the suite reaches all six late-added MXAccess **COM** commands
|
||||
but none of the five **control** commands (`Ping`, `GetSessionState`,
|
||||
`GetWorkerInfo`, `DrainEvents`, `ShutdownWorker`). Those are implemented off-STA in
|
||||
`Worker/Ipc/WorkerPipeSession.cs` and are asserted only against
|
||||
`FakeWorkerHarness`'s canned replies, so no test proves the *real* worker answers
|
||||
them. Closing that is the residual half of archreview TST-05.
|
||||
|
||||
Build the worker before running the smoke:
|
||||
|
||||
@@ -215,13 +225,21 @@ service described in `glauth.md`.
|
||||
|
||||
The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole`
|
||||
set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific
|
||||
dashboard-admin role and is **not** part of the five baseline GLAuth role
|
||||
dashboard-admin role and is **not** part of the baseline GLAuth role
|
||||
groups — it must be provisioned before the LDAP live tests pass.
|
||||
`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips)
|
||||
when GLAuth has only the baseline groups, so this is a hard prerequisite
|
||||
beyond "LDAP is up." See the "Adding a gw-specific group" section of
|
||||
`glauth.md` for the provisioning step that adds `GwAdmin` and grants it to
|
||||
`admin`.
|
||||
beyond "LDAP is up." The shared directory
|
||||
(`scadaproj/infra/glauth/config.toml`) already provisions `GwAdmin` (gid 5610)
|
||||
and `GwReader` (gid 5611); see the "Adding a gw-specific group" section of
|
||||
`glauth.md` for the per-box equivalent.
|
||||
|
||||
The fixtures name real users from that shared config, so a run only proves the
|
||||
service-account bind when it targets the shared directory. `appsettings.json`
|
||||
ships `Server=localhost` for the local-forward case, so point the suite at the
|
||||
shared GLAuth with `MxGateway__Ldap__Server=10.100.0.35`; the suite's
|
||||
`AddEnvironmentVariables()` layer applies the override to the same
|
||||
`MxGateway:Ldap` section production binds.
|
||||
|
||||
`DashboardAuthenticator` delegates the LDAP bind and group search to the shared
|
||||
`ZB.MOM.WW.Auth.Ldap` provider (`LdapAuthService`) and only maps the resulting
|
||||
@@ -229,29 +247,79 @@ groups to dashboard roles via `DashboardGroupRoleMapper`; the bind/search
|
||||
mechanics that decide each outcome live in that shared provider, not in
|
||||
`DashboardAuthenticator`.
|
||||
|
||||
The suite covers both the success path and the failure outcomes: `admin` whose
|
||||
LDAP groups resolve to the `Admin` role succeeds and emits the role claim;
|
||||
`readonly` is denied because no group in their `memberOf` appears in
|
||||
`GroupToRole`; `admin` with a wrong password fails authentication without leaking
|
||||
the password into `FailureMessage`; an unknown username fails authentication; and
|
||||
an unreachable LDAP server is absorbed into a failed result rather than throwing.
|
||||
The suite covers both the success path and the failure outcomes: `admin`, whose
|
||||
`othergroups` include `GwAdmin`, succeeds and emits the role claim — this is the
|
||||
one test that proves the service-account bind, because every other outcome below
|
||||
fails identically whether or not the bind credential is right; `gw-viewer` is
|
||||
denied because its only group (`GwReader`) is absent from `GroupToRole`, and its
|
||||
denial message must match the unknown-user denial so an authorization failure
|
||||
cannot be used to enumerate valid accounts; `admin` with a wrong password fails
|
||||
authentication without leaking the password into `FailureMessage`; an unknown
|
||||
username fails authentication; and an unreachable LDAP server is absorbed into a
|
||||
failed result rather than throwing. Both live users bind with the shared dev
|
||||
password documented in `glauth.md`.
|
||||
|
||||
`appsettings.json` now ships the LDAP bind password as the unexpanded
|
||||
`${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the
|
||||
pre-host secrets expander, which this suite's bare `ConfigurationBuilder`
|
||||
does not run). Before running the live LDAP suite, set
|
||||
`MxGateway__Ldap__ServiceAccountPassword` to the real GLAuth service-account
|
||||
password (dev value `serviceaccount123` for the shared GLAuth) so the suite
|
||||
binds with the real password instead of the literal token.
|
||||
password so the suite binds with the real password instead of the literal
|
||||
token. Obtain the current value from the GLAuth source of truth
|
||||
`scadaproj/infra/glauth/` (per `glauth.md`); it is not committed here.
|
||||
|
||||
Run the LDAP live tests explicitly:
|
||||
|
||||
```bash
|
||||
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
|
||||
$env:MxGateway__Ldap__ServiceAccountPassword = "serviceaccount123"
|
||||
$env:MxGateway__Ldap__Server = "10.100.0.35"
|
||||
$env:MxGateway__Ldap__ServiceAccountPassword = "<service-account-password>"
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
|
||||
```
|
||||
|
||||
## Client Wire Tests
|
||||
|
||||
Each client's own suite drives the client's public API against a **fake gateway
|
||||
served over a real gRPC transport** — an in-process or loopback server
|
||||
implementing `mxaccess_gateway.v1.MxAccessGateway`. Only the gateway's *behaviour*
|
||||
is canned; the HTTP/2 framing, protobuf serialization, call metadata, and gRPC
|
||||
status codes are genuine. That is the difference from the per-client mocks: a mock
|
||||
substituted for the generated stub (or, in .NET, for `IMxGatewayClientTransport`)
|
||||
proves what the client *intends* to send, never what a server *receives*, so a
|
||||
field the client fails to decode or a header it never actually attaches passes
|
||||
every mock-based test. These tests need no MXAccess, no worker, and no network
|
||||
beyond loopback, so they run in the default suite on every host.
|
||||
|
||||
The shared shape each client's wire test covers:
|
||||
|
||||
- **Round trip** — `OpenSession` → `Invoke` (a `Register`, asserting the decoded
|
||||
`RegisterReply.server_handle`) → `StreamEvents` (asserting the decoded
|
||||
`OnDataChange` fields) → `CloseSession`.
|
||||
- **Auth on the wire** — the `authorization: Bearer <key>` header is asserted as
|
||||
*observed by the server*, on the streaming RPC as well as the unary ones.
|
||||
- **Replay-gap sentinel** — a stream resumed with `after_worker_sequence` opens
|
||||
with the gateway's `replay_gap` sentinel, and the client surfaces it as its
|
||||
typed, non-terminal replay-gap signal rather than a normal event.
|
||||
- **Status mapping** — a real `PERMISSION_DENIED` from the server becomes the
|
||||
client's typed authorization error, not a bare transport exception.
|
||||
|
||||
Per-client harness and command:
|
||||
|
||||
| Client | Harness | Command |
|
||||
|---|---|---|
|
||||
| .NET | `WireFakeGatewayServer` (Kestrel h2c on `127.0.0.1:0`, `MxAccessGatewayBase`) — `MxGatewayClientWireTests` | `dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj` |
|
||||
| Python | `FakeGateway` + `serve_gateway` fixture (`grpc.aio` server on `127.0.0.1:0`) — `tests/test_wire_fake_gateway.py` | `python -m pytest` from `clients/python` |
|
||||
| Go | `fakeGatewayServer` + `newBufconnClient` (`grpc.NewServer` over `bufconn`) — `mxgateway/client_session_test.go` | `go test ./...` from `clients/go` |
|
||||
| Rust | `spawn_fake_gateway` (tonic `Server` over a loopback `TcpListener`) — `tests/client_behavior.rs` | `cargo test --workspace` from `clients/rust` |
|
||||
| Java | `TestGatewayService` + `InProcessGateway` (`InProcessServerBuilder`) — `MxGatewayClientSessionTests`; plus `InProcessGatewayHarness` for the CLI tests | `gradle test` from `clients/java` |
|
||||
|
||||
All five run in CI: Go, Rust, Python, and the .NET client tests in the `portable`
|
||||
job, Java in the `java` job.
|
||||
|
||||
Adding an RPC to `mxaccess_gateway.proto` does not automatically extend these —
|
||||
the fake gateways implement only the four session RPCs. Extend the fake in the
|
||||
client whose behaviour changed rather than adding a parallel harness.
|
||||
|
||||
## Client E2E Scripts
|
||||
|
||||
`scripts/discover-testmachine-tags.ps1` queries the ZB Galaxy Repository for the
|
||||
@@ -417,6 +485,125 @@ Run the gateway test project after shared gateway test infrastructure changes:
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj
|
||||
```
|
||||
|
||||
## Running the Gateway Suite on windev
|
||||
|
||||
The gateway suite (`ZB.MOM.WW.MxGateway.Tests`, net10.0/x64) is not part of the CI
|
||||
Windows tier — `windows-x86` and `nightly-windev` run only the x86 Worker build and
|
||||
`Worker.Tests`. It is still run on windev by hand when a change needs Windows
|
||||
confirmation, and that run has three Windows-specific characteristics worth knowing
|
||||
before results are interpreted.
|
||||
|
||||
Run it from an isolated clone under `C:\build` checked out to the SHA under test — never
|
||||
the dirty Desktop checkout, and never the CI clone `C:\build\mxaccessgw-ci`, whose worktree
|
||||
lock belongs to the Worker tier.
|
||||
|
||||
Baseline on an otherwise idle windev (2026-08-10): **879 passed, 0 failed, 31 s** — the same
|
||||
879 the macOS box runs, with nothing gated away. Any failure is therefore a real signal, but
|
||||
read the load caveat below before acting on one.
|
||||
|
||||
Runs before the pipe-buffer fix below reported 855, which was long read as "windev runs a
|
||||
smaller suite because some cases are gated to Unix". It was not: 855 is simply what had been
|
||||
flushed when the wedged host was torn down. Do not treat a short count on this suite as
|
||||
platform gating.
|
||||
|
||||
### Two long-standing "windev-environmental" failures were test bugs, not the environment
|
||||
|
||||
Both were dismissed as environmental for months and are now fixed. Neither depended on
|
||||
anything installed on windev; both failed on **any** Windows host:
|
||||
|
||||
- `SelfSignedCertificateProviderTests.GenerateCertificate_HasExpectedSansEkuAndValidity`
|
||||
asserted SAN content by substring-matching `X509Extension.Format(false)`. That string is
|
||||
produced by the platform crypto library: Windows' `CryptFormatObject` renders the IPv6
|
||||
loopback fully expanded (`IP Address=0000:0000:0000:0000:0000:0000:0000:0001`) while the
|
||||
managed formatter used on macOS/Linux renders `::1`, so the loopback assertion failed on
|
||||
Windows only. The test now decodes the extension with `X509SubjectAlternativeNameExtension`
|
||||
and compares parsed `IPAddress` values and DNS names (case-insensitively, as DNS names
|
||||
are), which is platform-independent.
|
||||
- `SessionManagerTests.OpenSessionAsync_PipeNameIsShortAndUniquePerPidAndSession` guards the
|
||||
104-byte macOS `sun_path` budget that NEXT-01 shortened the pipe name to fit. It padded the
|
||||
measured name up to a five-digit pid but never substituted that worst case *downward*, so a
|
||||
six-digit pid — routine on Windows, impossible on macOS, where pids stop at 99999 — made the
|
||||
name one character "too long" against a budget that does not apply to the host running the
|
||||
test. The check now replaces the running pid's digit count with the five-digit macOS worst
|
||||
case, so it measures the name *format* rather than the current process's pid.
|
||||
|
||||
### The real-pipe suites are load-sensitive
|
||||
|
||||
These suites drive real named pipes against a five-second worker startup timeout and start
|
||||
failing when windev is busy — most often when the x86 Worker tier is building or testing at
|
||||
the same time. All five passed in the idle baseline above and all five failed in a run taken
|
||||
while an x86 build and `Worker.Tests` were in flight (that run also took 2 m 21 s against the
|
||||
idle half-minute):
|
||||
|
||||
- `GatewayEndToEndFakeWorkerSmokeTests`, `GatewayEndToEndMultiSubscriberTests`,
|
||||
`GatewayEndToEndReconnectReplayTests` — fail as
|
||||
`RpcException Status(StatusCode="Unavailable", Detail="Failed to open session …")`.
|
||||
- `SessionWorkerClientFactoryFakeWorkerTests.CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException`
|
||||
— the startup timeout beats the protocol violation the test is asserting, so the observed
|
||||
exception is `TimeoutException` instead of `WorkerClientException`.
|
||||
- `WorkerClientTests.InvokeAsync_WhenCommandExceedsFrameMax_FailsOnlyThatCommandAndStaysReady`.
|
||||
- `EventStreamServiceTests.StreamEventsAsync_WithConcurrentStreams_TracksAggregateQueueDepth`
|
||||
— polls a metric against a five-second deadline. Its helper now reports the unmet condition
|
||||
rather than letting a bare `TaskCanceledException` escape, so a load-induced timeout here
|
||||
names what it was waiting for instead of looking like an unexplained cancellation.
|
||||
|
||||
A failure in that list is evidence about machine load, not about the change under test. Check
|
||||
for a concurrent x86 build/test (`Get-Process dotnet, testhost, testhost.net48.x86,
|
||||
MSBuild, VBCSCompiler`) and re-run the affected class on its own before treating it as real.
|
||||
windev has 36 logical CPUs and `xunit.runner.json` sets `maxParallelThreads: -1`, so the
|
||||
suite runs far wider there than on the macOS dev box — that width is what turns these
|
||||
real-clock deadlines into failures.
|
||||
|
||||
### The full-suite testhost hang was a zero-buffer named pipe (fixed)
|
||||
|
||||
For months a full-suite run on windev reported `855 passed, 0 failed` and then never
|
||||
returned: the x64 `testhost` stopped consuming CPU but stayed alive indefinitely, and the run
|
||||
had to be killed with `--blame-hang`. That guard is no longer needed — run the suite plainly:
|
||||
|
||||
```powershell
|
||||
dotnet test src\ZB.MOM.WW.MxGateway.Tests\ZB.MOM.WW.MxGateway.Tests.csproj
|
||||
```
|
||||
|
||||
The cause is worth recording because the shape of it is easy to hit again.
|
||||
|
||||
`dotnet-stack report` on the wedged host showed no thread running test code; xUnit's
|
||||
`RunTestsInAssembly` was simply parked on `WaitHandle.WaitOne()` waiting for the
|
||||
assembly-finished event. The wait was therefore in a suspended async state machine, which only
|
||||
`dotnet-dump analyze <dump> -c dumpasync` can see. It named the exact frame:
|
||||
`WorkerClientTests.StagingChannelOverflowFaultsWorkerWithoutWaitingForFullModeTimeout`
|
||||
awaiting `WorkerFrameWriter.WriteAsync` — a 63-byte pipe write that never completed. The `855`
|
||||
was never the whole suite: the same clone now reports 879, so the wedge was also costing 24
|
||||
results, and the summary still looked clean because the hung test is not counted as a failure.
|
||||
|
||||
That test pushes events past the worker client's staging bound to prove the client faults, and
|
||||
after the fault the client's read loop stops reading by design. The test-side pipe was created
|
||||
through `NamedPipeServerStream(string, PipeDirection, int, PipeTransmissionMode, PipeOptions)`,
|
||||
whose omitted buffer arguments become `inBufferSize: 0` / `outBufferSize: 0`. On Windows that
|
||||
reserves *no* buffer: a write completes only when the peer reads it. Measured directly on
|
||||
windev, that pipe absorbed **0 bytes** before blocking against a non-reading peer, while the
|
||||
same pipe declared with 64 KiB buffers absorbed **65 520**. On macOS and Linux .NET backs named
|
||||
pipes with Unix domain sockets, whose socket buffer swallows a few kilobytes regardless — which
|
||||
is why the identical test never hung there, and why the bug read as "a windev thing".
|
||||
|
||||
Two changes make it structural rather than incidental:
|
||||
|
||||
- Test-owned server pipes are created through `TestSupport/TestNamedPipe.CreateServer`, which
|
||||
declares explicit 64 KiB buffers, in both the gateway and worker test projects. This scopes
|
||||
those tests to the backpressure they are actually asserting — the gateway's staging and event
|
||||
queues — instead of the OS pipe's flow control.
|
||||
- Every fake-worker write in `WorkerClientTests` goes through `PipePair.WriteAsync`, which
|
||||
bounds the write by the class's five-second `TestTimeout` and fails with a message naming the
|
||||
stopped reader. A blocked write is now a named test failure rather than a silent wedge.
|
||||
|
||||
The severity came from the second point being missing, not the first. A test method that never
|
||||
returns keeps xUnit from raising `ITestAssemblyFinished`, so the runner waits forever and
|
||||
`testhost` never exits — one unbounded `await` in one test costs the entire suite its result.
|
||||
Any new test that writes to a pipe whose reader may stop must bound the write.
|
||||
|
||||
The gateway's production pipe in `SessionWorkerClientFactory.CreatePipe` deliberately keeps the
|
||||
unbuffered declaration: both ends run continuous read loops and every write there is bounded by
|
||||
the worker client's `_stopCts`, so a stalled peer cancels the write instead of blocking on it.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
CI runs on Gitea Actions (`.gitea/workflows/ci.yml`; origin is Gitea at
|
||||
@@ -428,10 +615,13 @@ runtime because the x86 Worker cannot build on Linux:
|
||||
gateway fake-worker tests, and builds/tests the clients that run on Linux: .NET client
|
||||
build, Go (`gofmt` + `go build` + `go test`), Rust (`cargo fmt --check` + `cargo test`
|
||||
+ `cargo clippy -D warnings`), and Python (`pytest`).
|
||||
- **`java`** (Linux, JDK 17) — `gradle test`. The protobuf gradle plugin rewrites
|
||||
`MxaccessGateway.java` with spurious protobuf-runtime-version churn on every build, so
|
||||
when no `.proto` changed the job reverts that one file (`git checkout`) before asserting
|
||||
the generated tree is clean. The dev Mac has no JRE, so Java verification is CI-only.
|
||||
- **`java`** (Linux, JDK 17) — `gradle test`, then `git diff --exit-code` over the generated
|
||||
tree. The grpc/protobuf toolchain is pinned (`clients/java/build.gradle`), so a regeneration
|
||||
is byte-identical to the committed single-file aggregates modulo real `.proto` changes; the
|
||||
git-diff is therefore a true drift gate that now catches message-level proto drift in the Java
|
||||
client (IPC-24 deleted the old unconditional churn-revert step, which masked exactly that
|
||||
class). The dev Mac has a Homebrew JDK 17, so `gradle generateProto` can be run there to refresh
|
||||
the Java aggregates when a `.proto` changes.
|
||||
- **`windows-x86`** (Linux runner, per push/PR) — builds the **x86 / net48 Worker and
|
||||
Worker.Tests**, which are Windows-only and out of scope for the Linux jobs. It runs on a
|
||||
Linux runner that always schedules and SSHes to windev (`10.100.0.48`), where
|
||||
@@ -449,21 +639,55 @@ runtime because the x86 Worker cannot build on Linux:
|
||||
`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.
|
||||
|
||||
The freshness guard `scripts/check-codegen.ps1` fails the build when the committed
|
||||
client descriptor set or the C# `Generated/` no longer matches the current `.proto`
|
||||
sources — the codegen drift class this repo has hit repeatedly (stale client
|
||||
descriptors, net48 `CS0246` on unregenerated protos). The **primary** guard for the
|
||||
### Runner capacity is shared and finite
|
||||
|
||||
CI runs on two co-located runner containers on docker host `10.100.0.35` — `gitea-runner`
|
||||
(capacity 4) and `gitea-runner-2` (capacity 2, registered 2026-08-07 per
|
||||
`docs/runbooks/TST-30-second-ci-runner.md`) — and both runner instances are **shared across
|
||||
repos**: they interleave `dohertj2/mxaccessgw` and `dohertj2/lmxopcua` jobs across the
|
||||
combined slots rather than being scoped to this repo (`GET
|
||||
/repos/dohertj2/mxaccessgw/actions/runners` returns `total_count: 0`; both runners are
|
||||
registered at the instance level). Every job in a run (`portable`, `java`, `windows-x86`)
|
||||
still executes serially within that run, so queue latency is additive within a run, but an
|
||||
active `lmxopcua` run no longer blocks `mxaccessgw` entirely the way a single shared slot
|
||||
did — the two runners relieve cross-repo contention. This Gitea version (1.26) also exposes
|
||||
**no run cancel or delete via the API** (`POST .../actions/runs/{id}/cancel` returns 404,
|
||||
`DELETE .../actions/runs/{id}` returns 400), so a superseded or hung run cannot be cleared
|
||||
and holds the slot until it finishes or times out — with two runners this means a single
|
||||
wedged run can still hold slots, because the no-cancel reality is unchanged. See
|
||||
`docs/runbooks/TST-30-second-ci-runner.md` for the operator runbook that registered the
|
||||
second runner.
|
||||
|
||||
When queue depth (or the missing-cancel reality) makes waiting impractical, verify a
|
||||
specific commit out of band instead of waiting behind the queue: run
|
||||
`CI_SHA=<sha> scripts/ci/run-windev-ci.sh <build|test|live>` from a machine with SSH access
|
||||
to windev (the same script the SSH-driven `windows-x86`/`nightly-windev` jobs use — see
|
||||
`scripts/ci/README.md`), or fall back to the manual windev worktree procedure below. This is
|
||||
the same escape hatch used when the windev tier itself is down — TST-30 generalizes it from
|
||||
"tier down" to "runner contended": either way, a stuck or slow shared runner should not
|
||||
block verifying a commit.
|
||||
|
||||
The freshness guard `scripts/check-codegen.ps1` runs four checks and fails the build when the
|
||||
committed client descriptor set (Check 1), the C# `Generated/` (Check 2), the Rust vendored
|
||||
protos (Check 3), or the Go/Python client bindings (Check 4, IPC-25) no longer match the current
|
||||
`.proto` sources — the codegen drift class this repo has hit repeatedly (stale client
|
||||
descriptors, net48 `CS0246` on unregenerated protos, silently stale Go/Python worker bindings).
|
||||
Check 4 regenerates the Go and Python bindings with their pinned generators (`protoc-gen-go`
|
||||
v1.36.11 / `protoc-gen-go-grpc` 1.6.2, `grpcio-tools` 1.80.0) and fails on any diff; a missing
|
||||
generator fails the check rather than skipping it. The **primary** guard for the
|
||||
"regenerate and commit `Generated/`" rule is that regeneration diff in the `portable` job;
|
||||
the `windows-x86` net48 compile is the **secondary** guard (a stale `Generated/` also breaks
|
||||
the x86 build with `CS0246`). See [Client Proto Generation](./ClientProtoGeneration.md) and
|
||||
[Contracts](./Contracts.md).
|
||||
|
||||
If the SSH-driven Windows tier is unavailable for infrastructure reasons (windev down, CI
|
||||
key/secret rotation in flight), fall back to the manual windev worktree procedure as a
|
||||
degraded mode: on windev, fast-forward an isolated `origin/main` worktree under `C:\build`
|
||||
(never the dirty Desktop checkout), then run the x86 Worker build and `Worker.Tests`
|
||||
(`-p:Platform=x86`) there by hand. Do this per merge for worker-touching changes until the
|
||||
`windows-x86` job is green again.
|
||||
key/secret rotation in flight) **or** the shared Gitea runner is contended and the queue is
|
||||
impractical to wait behind (see "Runner capacity is shared and finite" above), fall back to
|
||||
the manual windev worktree procedure as a degraded mode: on windev, fast-forward an isolated
|
||||
`origin/main` worktree under `C:\build` (never the dirty Desktop checkout), then run the x86
|
||||
Worker build and `Worker.Tests` (`-p:Platform=x86`) there by hand. Do this per merge for
|
||||
worker-touching changes until the `windows-x86` job is green again (tier-down case) or the
|
||||
queue clears (contention case).
|
||||
|
||||
## Related Documentation
|
||||
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClien
|
||||
|
||||
`GatewaySession` is a sealed class that holds the identity, configured timeouts, worker client reference, and current `SessionState` for one session. State is protected by a private `_syncRoot` lock so that property reads and transitions are observed atomically by concurrent gRPC calls and the lease sweeper.
|
||||
|
||||
The session id is an opaque string in the form `session-{guid:N}` and the per-session pipe name is `mxaccess-gateway-{ProcessId}-{SessionId}`. Encoding the gateway PID into the pipe name avoids collisions when an old gateway process leaks pipes that the OS has not yet reclaimed.
|
||||
The session id is an opaque string in the form `session-{guid:N}` and the per-session pipe name is `mxgw-{ProcessId}-{guid:N}` (the same guid hex, without the `session-` prefix). Encoding the gateway PID into the pipe name avoids collisions when an old gateway process leaks pipes that the OS has not yet reclaimed. The name is kept short because .NET named pipes on Unix-like hosts are Unix domain sockets at `$TMPDIR/CoreFxPipe_{name}`, and macOS caps that path at 104 bytes while its default per-user `TMPDIR` is already ~49 — the old `mxaccess-gateway-{pid}-{sessionId}` form overflowed it and broke the fake-worker/e2e tests on macOS.
|
||||
|
||||
`SessionState` itself is the protobuf-generated enum from `ZB.MOM.WW.MxGateway.Contracts.Proto`, so it is shared between the gateway and clients on the wire.
|
||||
|
||||
@@ -201,7 +201,7 @@ The single worker event channel has exactly one direct reader: the `SessionEvent
|
||||
|
||||
The monitor takes that lease **before** it issues `SubscribeAlarms`, so no transition window is missed: the pump has been running since `MarkReady` started the dashboard mirror, and the distributor only fans to subscribers registered at fan-out time, so a lease taken after the subscribe + first-reconcile round trips would lose every transition raised inside that window. Transitions arriving while the monitor subscribes and reconciles buffer in the lease's bounded channel and are applied after the reconcile, which is order-safe because a live transition can update an alarm the reconciled snapshot already holds.
|
||||
|
||||
The repair transitions the monitor's reconcile broadcasts on the alarm feed (Raise/Clear presence deltas and the acked-state delta) are **at-least-once**: a reconcile reads the worker's current state while the matching live transition may still be buffered in the lease, so both can be broadcast and the duplicates are indistinguishable — alarm-feed consumers must apply transitions idempotently. This is a property of the reconcile design, not of the buffering above.
|
||||
The repair transitions the monitor's reconcile broadcasts on the alarm feed (Raise/Clear presence deltas and the acked-state delta) are **at-least-once**: a reconcile reads the worker's current state while the matching live transition may still be buffered in the lease, so both can be broadcast and the duplicates are indistinguishable — alarm-feed consumers must apply transitions idempotently. This is a property of the reconcile design, not of the buffering above. The monitor dedups the common case best-effort (NEXT-03): a buffered live transition that positively matches the cache's worker timestamp and resulting state — or, for Clear, a tombstone keyed on the cleared instance's original raise timestamp — was already broadcast as a repair and is suppressed; unset timestamps never suppress, so the consumer contract is unchanged.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Env
|
||||
| C compiler x86 | 14.44.35207 | `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207\bin\Hostx64\x86\cl.exe` |
|
||||
| Linker x86 | 14.44.35207 | `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.44.35207\bin\Hostx64\x86\link.exe` |
|
||||
| LibMan CLI | 3.0.71 | `C:\Users\dohertj2\.dotnet\tools\libman.exe` |
|
||||
| dotnet-stack | 9.0.661903 | `C:\Users\dohertj2\.dotnet\tools\dotnet-stack.exe` |
|
||||
| dotnet-dump | 9.0.661903 | `C:\Users\dohertj2\.dotnet\tools\dotnet-dump.exe` |
|
||||
|
||||
`dotnet-stack` and `dotnet-dump` are the diagnostics pair for a process that stops
|
||||
making progress but does not exit. `dotnet-stack report -p <pid>` prints every
|
||||
managed thread's stack, which is enough when a *thread* is blocked; when nothing is
|
||||
on a thread the wait lives in a suspended async state machine, and only
|
||||
`dotnet-dump collect -p <pid>` followed by `dotnet-dump analyze <dump> -c dumpasync`
|
||||
reveals it. Both were installed user-local with `dotnet tool install -g` while
|
||||
root-causing the windev test-host hang described in `docs/GatewayTesting.md`.
|
||||
|
||||
Reference assemblies:
|
||||
|
||||
|
||||
@@ -123,7 +123,11 @@ runs after the whole batch, and only then does every successfully-written
|
||||
frame's completion resolve — so a caller's `WriteAsync` still does not
|
||||
complete until its bytes are both written *and* flushed, but a batch that
|
||||
happened to contain several queued frames pays one flush instead of one per
|
||||
frame. The event drain loop (`WorkerPipeSession.RunEventDrainLoopAsync`)
|
||||
frame. Note the ordering this implies at the peer: the frames reach the pipe
|
||||
before the flush that follows them, so the gateway can read a whole batch
|
||||
while the writer has not yet flushed it. Anything observing the flush itself
|
||||
(a test counting flushes, for instance) must wait for the flush, not infer it
|
||||
from frames arriving. The event drain loop (`WorkerPipeSession.RunEventDrainLoopAsync`)
|
||||
submits a whole drained event batch through `WriteBatchAsync`, which enqueues
|
||||
every frame under one `_gate` acquisition, takes the write lock once, and
|
||||
drains them together, so a burst of N events costs one flush rather than N —
|
||||
@@ -147,6 +151,19 @@ so the caller observes `OperationCanceledException` while that one frame still
|
||||
reaches the wire. That residual window is by design: blocking the canceller
|
||||
behind the very write it is abandoning would defeat the point of cancellation.
|
||||
|
||||
Two hygiene notes on that residual (NEXT-04/NEXT-05). First, a frame the
|
||||
cancelled caller abandons — claimed mid-write, or already faulted by a
|
||||
concurrent queue-wide failure — completes on a task nobody awaits; the
|
||||
tombstone path attaches a fault-observing continuation to it so a later write
|
||||
failure never surfaces as a `TaskScheduler.UnobservedTaskException`. Second,
|
||||
tombstoned entries stay in the class queues until a future `DequeueNext` pops
|
||||
and skips them; that lazy purge is deliberate. Eagerly rebuilding a `Queue<T>`
|
||||
under `_gate` on every cancellation would add ordering-invariant surface next
|
||||
to the claim/cancel interlock for no real gain: any subsequent write of either
|
||||
class drains both queues to empty, and the heartbeat loop guarantees one
|
||||
arrives within a heartbeat interval, so worst-case residency is a few envelope
|
||||
references for seconds — not a leak.
|
||||
|
||||
## Verification
|
||||
|
||||
The frame protocol lives in `ZB.MOM.WW.MxGateway.Worker.Ipc` (`WorkerFrameReader`,
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# Follow-Ups: windev Redeploy, LDAP Test Fixtures, Runner Hygiene — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
|
||||
> (opus implementers; controller verifies ops evidence; final review pass).
|
||||
|
||||
**Goal:** Close the five items surfaced by the 2026-08-07 live-actions cycle: repair windev's
|
||||
crash-looping gateway and finish the deferred SEC-36 dashboard verification (NEXT-07), fix the
|
||||
DashboardLdapLiveTests fixture drift (NEXT-06), resolve the unexpected macOS instance runner,
|
||||
harden runner-1's plaintext registration token, and verify the cargo Bearer fix.
|
||||
|
||||
**Architecture:** Three independent live streams (windev serial: 1→2→3; repo test fix: 4;
|
||||
Gitea/runner hygiene: 5, 6) run concurrently; task 7 closes out docs/trackers. No contract,
|
||||
gateway-logic, or client changes — one test-file edit (Task 4) plus live ops plus docs.
|
||||
|
||||
**Tech Stack:** SSH + PowerShell `-EncodedCommand` (windev 10.100.0.48), SSH + docker compose
|
||||
(10.100.0.35), Gitea admin API (`gitea.dohertylan.com`, token via `~/.zshenv` `GITEA_TOKEN`),
|
||||
NSSM, xUnit live-LDAP suite, GLAuth at `10.100.0.35:3893`.
|
||||
|
||||
---
|
||||
|
||||
## Preflight facts (verified before planning)
|
||||
|
||||
- Unpushed local mxaccessgw commits: `0566716`, `9760497`, `5b153da`, `41e8648` (all docs-only).
|
||||
**`origin/main` = `a346d51`** — contains all current code, so windev can build from
|
||||
`origin/main` without any push.
|
||||
- windev (`10.100.0.48`): NSSM service `MxAccessGw`; deployed Server build of 2026-06-25
|
||||
(Auth 0.1.2.0, supports auth-DB schema 2) crash-loops on
|
||||
`C:\ProgramData\MxGateway\gateway-auth.db` migrated to schema 3 on 2026-07-15
|
||||
(`AuthStoreMigrationException`, ~3.8k–10k Hosting-failed events/day). The NEW LDAP secret is
|
||||
already staged as the 10th `AppEnvironmentExtra` entry (SEC-36 Task 3) — preserve it.
|
||||
- `DashboardLdapLiveTests.cs` (`src/ZB.MOM.WW.MxGateway.IntegrationTests/`): uses
|
||||
`admin`/`admin123` (3 tests) and `readonly`/`readonly123`. Directory reality
|
||||
(`scadaproj/infra/glauth/config.toml`): `admin` exists, password is the standard dev test
|
||||
password (`password`, hash `5e884898…42d8` — same as `multi-role`), and IS in GwAdmin
|
||||
(othergroups `[5610, 5701]`); `readonly` does not exist; `gw-viewer` (primarygroup 5611 =
|
||||
GwReader, NOT GwAdmin) is the natural not-an-admin fixture. Test binds
|
||||
`MxGateway:Ldap` from `appsettings.json` (**`Server: localhost`**) + env overrides — so the
|
||||
live run needs `MxGateway__Ldap__Server=10.100.0.35` as well as
|
||||
`MxGateway__Ldap__ServiceAccountPassword` (from Mac user-secrets, never printed).
|
||||
- Gitea instance runners (`GET /api/v1/admin/actions/runners`): id 1 `gitea-runner` (cap 4),
|
||||
id 4 `macos-local-Josephs-MacBook-Pro` (**unexpected, online, labels overlap
|
||||
ubuntu-latest**), id 5 `gitea-runner-2` (cap 2).
|
||||
- `10.100.0.35:/opt/gitea/docker-compose.yml` (+ `docker-compose.yml.bak-tst30`): runner-1's
|
||||
registration token inline in plaintext env, file world-readable. runner-2 uses
|
||||
`GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /run/secrets/runner_token` ← 0600
|
||||
`/opt/gitea/runner_token`. runner-1 data volume `/opt/gitea/runner:/data` (its `.runner`
|
||||
credential persists — the registration env is only needed for first registration).
|
||||
- Cargo Bearer fix already applied (`~/.zshenv`, backup `~/.zshenv.bak-cli39`) and documented
|
||||
(`docs/ClientPackaging.md`, commit `5b153da`). Task 7 verifies; no further action expected.
|
||||
|
||||
## Secret hygiene (binding, all tasks)
|
||||
|
||||
- Never print the LDAP service-account password, `GITEA_TOKEN`, cargo token, runner
|
||||
registration tokens, or API keys — not in commands, logs, commits, or reports. Read the LDAP
|
||||
password from `dotnet user-secrets list` into an env var without echoing
|
||||
(e.g. `export MxGateway__Ldap__ServiceAccountPassword="$(dotnet user-secrets list --project src/ZB.MOM.WW.MxGateway.Server | awk -F' = ' '/ServiceAccountPassword/ {print $2}')"`).
|
||||
- Documented dev **test users** (`multi-role`/`password`, `admin`/`password`,
|
||||
`gw-viewer`/`password`) are NOT secrets — glauth.md publishes them; fine in code/commits.
|
||||
- SSH→windev PowerShell: always `powershell -NoProfile -EncodedCommand <base64-UTF16LE>`;
|
||||
never put secrets inside EncodedCommand blobs or argv.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: NEXT-07 — Recon windev deployment layout + schema support
|
||||
|
||||
**Classification:** standard — read-only recon, but its output gates a service redeploy
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 4, Task 5, Task 6
|
||||
|
||||
**Files:** none edited. SSH recon on `10.100.0.48` + repo/scadaproj reads on the Mac.
|
||||
|
||||
Determine everything Task 2 needs, and confirm the fresh-deploy path is safe:
|
||||
|
||||
1. `nssm get MxAccessGw Application`, `AppDirectory`, `AppParameters`,
|
||||
`AppEnvironmentExtra` (count entries; do NOT print values of secret-bearing entries —
|
||||
names only).
|
||||
2. Inventory the deployed dir: path, `ZB.MOM.WW.MxGateway.Server.exe` timestamp, whether
|
||||
`appsettings.json`/`appsettings.Production.json` in the deploy dir differ from repo
|
||||
`origin/main` (diff; windev-specific config must survive the redeploy).
|
||||
3. Confirm build feasibility on windev: `dotnet --list-sdks` (need 10.x), locate an existing
|
||||
mxaccessgw checkout/worktree (CI uses `scripts/ci/windev-worker-ci.ps1` — find its
|
||||
worktree path) or pick a fresh clone location. Confirm `git fetch` reaches `origin/main`
|
||||
= `a346d514dd24e775640e5667aa7cd8e561fec68a`.
|
||||
4. Confirm current code supports auth-DB schema 3: find the auth-store supported-schema
|
||||
constant (ZB.MOM.WW.Auth packages — check the package version the Server at `origin/main`
|
||||
references, and/or the migration code in the shared scadaproj libs) and state the
|
||||
evidence. **If current code does NOT support schema 3, STOP — report, do not deploy.**
|
||||
5. Gateway endpoints for verification: bound URLs/ports (from deployed config/env), dashboard
|
||||
scheme (http vs https → cookie will be `MxGatewayDashboard` vs `__Host-…`).
|
||||
6. Check what migrated the DB to schema 3 on 2026-07-15 (event log / file timestamps) — only
|
||||
to confirm schema 3 is the shared-lib current version, not an anomaly.
|
||||
|
||||
**Step: report** all findings as structured text (no secrets); no changes, no commits.
|
||||
|
||||
### Task 2: NEXT-07 — Build current Server on windev and redeploy the service
|
||||
|
||||
**Classification:** high-risk — replaces a running (crash-looping) service's binaries
|
||||
**Estimated implement time:** ~10 min
|
||||
**Parallelizable with:** none (needs Task 1)
|
||||
|
||||
**Files:** none in repo. windev filesystem + NSSM only.
|
||||
|
||||
Using Task 1's facts:
|
||||
|
||||
1. On windev, fetch/checkout `origin/main` (`a346d51…`) in the build worktree/clone.
|
||||
2. `dotnet publish src/ZB.MOM.WW.MxGateway.Server -c Release` (match deployed layout/RID from
|
||||
Task 1; framework-dependent vs self-contained must match what NSSM `Application` points at).
|
||||
3. Stop the service (`nssm stop MxAccessGw`), confirm process exited.
|
||||
4. Backup: deployed dir → sibling `*.bak-next07` copy; copy
|
||||
`C:\ProgramData\MxGateway\gateway-auth.db` (+ `-wal`/`-shm` if present) to
|
||||
`gateway-auth.db.bak-next07`. **Never delete the live DB.**
|
||||
5. Deploy publish output over the deploy dir, then restore any windev-specific config files
|
||||
identified in Task 1 (do not clobber live overrides; NSSM env entries are untouched by
|
||||
file copies but verify count unchanged after start).
|
||||
6. `nssm start MxAccessGw`; verify: service state RUNNING and stable ≥60 s (no restart
|
||||
cycle), Application event log shows clean host start and **zero new
|
||||
`AuthStoreMigrationException` / `Hosting failed to start`** after the start timestamp,
|
||||
bound port answers (e.g. dashboard root or health endpoint returns HTTP).
|
||||
7. Rollback if unhealthy: stop, restore `*.bak-next07` dir, start, report.
|
||||
|
||||
**Step: report** deployed SHA, verification evidence, backup paths. No repo commits.
|
||||
|
||||
### Task 3: SEC-36 deferred verification + NEXT-07/runbook closeout
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~6 min
|
||||
**Parallelizable with:** none (needs Task 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/runbooks/SEC-36-ldap-credential-rotation.md` (Correction 3 — mark the
|
||||
deferred dashboard check done, dated)
|
||||
- Modify: `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (NEXT-07 →
|
||||
resolved 2026-08-07, evidence one-liner)
|
||||
|
||||
1. Complete SEC-36 step 4 against the repaired windev gateway: log in to the dashboard as
|
||||
`multi-role`/`password` end-to-end. Preferred: `curl` flow — GET `/login` (capture
|
||||
antiforgery token + cookie), POST credentials, expect success redirect + auth cookie
|
||||
(name per Task 1 scheme). If the login page resists scripting (Blazor circuit), report
|
||||
exactly why and fall back to asserting a fresh `DashboardLdapLiveTests` green run
|
||||
(Task 4) plus windev log evidence of successful LDAP bind on a manual attempt.
|
||||
2. Update the two docs; commit locally (`docs(sec-36,next-07): …`), do NOT push.
|
||||
|
||||
### Task 4: NEXT-06 — Fix DashboardLdapLiveTests fixtures to match the shared directory
|
||||
|
||||
**Classification:** small — one test file, but must go green against live GLAuth
|
||||
**Estimated implement time:** ~6 min
|
||||
**Parallelizable with:** Task 1, Task 5, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs`
|
||||
- Modify: `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (NEXT-06 →
|
||||
resolved)
|
||||
- Possibly modify: `docs/GatewayTesting.md` (live-LDAP opt-in row: document the
|
||||
`MxGateway__Ldap__Server` override needed when GLAuth is not localhost)
|
||||
|
||||
1. Read `DashboardAuthenticator` first: confirm a user who binds successfully but maps to no
|
||||
role yields `Succeeded == false` (drives the gw-viewer fixture).
|
||||
2. Fix fixtures: `admin`/`admin123` → `admin`/`password` (positive + wrong-password +
|
||||
unreachable tests); `readonly`/`readonly123` → `gw-viewer`/`password` (exercises
|
||||
user-binds-but-lacks-GwAdmin; keep the no-password-leak assertion, updating the asserted
|
||||
literal). Update XML doc comments to match. Keep MXAccess-repo style rules
|
||||
(TreatWarningsAsErrors).
|
||||
3. Build: `dotnet build src/ZB.MOM.WW.MxGateway.IntegrationTests` (macOS OK — net10.0).
|
||||
4. Live run (env only, never echo the password):
|
||||
`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1 MxGateway__Ldap__Server=10.100.0.35 MxGateway__Ldap__ServiceAccountPassword=<from user-secrets> dotnet test … --filter FullyQualifiedName~DashboardLdapLiveTests`
|
||||
→ expect **5/5 passed** (this is also positive live proof of the SEC-36 service-account
|
||||
bind).
|
||||
5. Update tracker row (+ GatewayTesting.md if the Server-override note is missing); commit
|
||||
locally (`test(ldap): …`), do NOT push.
|
||||
|
||||
### Task 5: Resolve the unexpected macOS instance runner (id 4)
|
||||
|
||||
**Classification:** standard — evidence-gated removal of a live runner registration
|
||||
**Estimated implement time:** ~6 min
|
||||
**Parallelizable with:** Task 1, Task 4, Task 6
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/runbooks/TST-30-second-ci-runner.md` (the Correction paragraph mentions
|
||||
"id 4 — an unrelated local macOS runner" — update to final state)
|
||||
|
||||
1. Evidence, local: `pgrep -fl act_runner`, `launchctl list | grep -i act`,
|
||||
`brew services list | grep -i act`, look for `~/.runner`/act_runner config dirs. Evidence,
|
||||
Gitea (token from `~/.zshenv`, never printed): runner detail for id 4 (labels, last
|
||||
online), and whether any recent runs' jobs report `runner_id == 4`
|
||||
(`GET /repos/{owner}/{repo}/actions/runs?…` → `…/runs/{id}/jobs` for both `mxaccessgw`
|
||||
and `lmxopcua` recent runs).
|
||||
2. Decision rule: the runner advertises ubuntu labels from a macOS host, so it can steal
|
||||
Linux container jobs → **remove it** unless evidence shows it deliberately serves jobs
|
||||
the docker runners cannot (none expected). Removal = stop the local act_runner process
|
||||
AND disable its autostart (launchd/brew), then `DELETE /api/v1/admin/actions/runners/4`.
|
||||
Keep the local config file (renamed `*.disabled-2026-08-07`) so re-registering with
|
||||
mac-specific labels stays easy; note the re-registration recipe in the runbook edit.
|
||||
3. Verify: admin runner list shows only ids 1 and 5, both online; no act_runner process
|
||||
locally; a `pgrep` after 60 s still empty (nothing respawned).
|
||||
4. Update the TST-30 runbook correction paragraph; commit locally, do NOT push.
|
||||
|
||||
### Task 6: Harden runner-1's registration token on 10.100.0.35
|
||||
|
||||
**Classification:** standard — touches the live CI stack's compose file
|
||||
**Estimated implement time:** ~7 min
|
||||
**Parallelizable with:** Task 1, Task 4, Task 5
|
||||
|
||||
**Files:** none in repo (host `/opt/gitea/` only; runbook note lands in Task 7 if needed).
|
||||
|
||||
1. Preconditions on the host: confirm runner-1's `/data/.runner` exists in its volume
|
||||
(registration credential persists → the registration env var is no longer needed);
|
||||
confirm both runners idle (no `act_runner`-spawned job containers, no in-progress runs
|
||||
via API) before recreating.
|
||||
2. Edit `/opt/gitea/docker-compose.yml` (backup first → `docker-compose.yml.bak-tst30b`):
|
||||
replace runner-1's inline `GITEA_RUNNER_REGISTRATION_TOKEN: <plaintext>` with the same
|
||||
`_FILE`/secrets pattern runner-2 uses (`/opt/gitea/runner_token`, 0600). Do NOT touch the
|
||||
`gitea` service definition.
|
||||
3. `docker compose up -d --no-deps` the runner-1 service only; verify it comes back online
|
||||
in the admin runner list and its `.runner` identity is unchanged (still id 1).
|
||||
4. Tighten perms: `chmod 600 /opt/gitea/docker-compose.yml docker-compose.yml.bak-tst30 docker-compose.yml.bak-tst30b`
|
||||
(verify compose stack still operable by the deploy user).
|
||||
5. Rotate the leaked registration token if the deployment allows:
|
||||
`docker exec … gitea actions generate-runner-token` (or admin API) — if Gitea offers no
|
||||
invalidation of the old value, say so explicitly in the report (residual risk: LAN actor
|
||||
could register a rogue runner until rotation) rather than claiming it rotated.
|
||||
6. Verify CI still works: trigger nothing; just confirm both runners online and the token
|
||||
file perms; a real push lands naturally later. Report evidence.
|
||||
|
||||
### Task 7: Closeout — cargo Bearer verification, docs/tracker sync, commits
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (needs Tasks 3, 4, 5, 6)
|
||||
|
||||
**Files:**
|
||||
- Modify: `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (final
|
||||
state of NEXT-06/NEXT-07 rows if Tasks 3/4 left anything)
|
||||
- Possibly modify: `docs/GatewayTesting.md` / TST-30 runbook (runner topology now ids 1+5
|
||||
only; token-hardening note)
|
||||
|
||||
1. Cargo Bearer verification (no printing): assert `~/.zshenv` line matches
|
||||
`CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN="Bearer …"` via `grep -c`, confirm
|
||||
`docs/ClientPackaging.md` note present (commit `5b153da`); check no other credential
|
||||
location (CI secrets, windev profiles) publishes to cargo — expected none.
|
||||
2. Sweep: every doc touched this cycle consistent (runbooks, trackers, GatewayTesting.md);
|
||||
`git grep` for stale phrases ("crash-loop… pending", "id 4", "admin123") and fix.
|
||||
3. Commit remaining doc changes locally; do NOT push. List the full unpushed stack in the
|
||||
report.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Pushing any mxaccessgw commits (user decides; stack listed at closeout).
|
||||
- The five next-cycle candidate findings other than NEXT-06/NEXT-07.
|
||||
- Auth-DB restore path for windev (fresh deploy chosen — preserves schema-3 data).
|
||||
- `ci.yml` changes (labels, concurrency groups).
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
{1} → 2 → 3 ┐
|
||||
{4} ├→ 7
|
||||
{5} │
|
||||
{6} ─────────┘
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-07-followups-windev-ldapfixtures-runners.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: NEXT-07 — Recon windev deployment layout + schema support", "status": "completed"},
|
||||
{"id": 2, "subject": "Task 2: NEXT-07 — Build current Server on windev and redeploy the service", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: SEC-36 deferred verification + NEXT-07/runbook closeout", "status": "completed", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: NEXT-06 — Fix DashboardLdapLiveTests fixtures to match the shared directory", "status": "completed"},
|
||||
{"id": 5, "subject": "Task 5: Resolve the unexpected macOS instance runner (id 4)", "status": "completed"},
|
||||
{"id": 6, "subject": "Task 6: Harden runner-1's registration token on 10.100.0.35", "status": "completed"},
|
||||
{"id": 7, "subject": "Task 7: Closeout — cargo Bearer verification, docs/tracker sync, commits", "status": "completed", "blockedBy": [3, 4, 5, 6]}
|
||||
],
|
||||
"lastUpdated": "2026-08-07 (all tasks executed; SEC-36 verification done during Task 2's foreground smoke test; 8 commits local on main, not pushed; one pending operator action: Gitea registration-token UI reset)"
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
# Live Actions: SEC-36 Rotation, TST-30 Second Runner, Client Publish — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task (or subagent-driven-development in-session).
|
||||
|
||||
**Goal:** Execute the three repo-complete-but-live-pending operator actions: rotate the dev GLAuth service-account credential (SEC-36), register a second Gitea Actions runner (TST-30), and publish the five client packages at 0.2.0 (Java 0.2.1).
|
||||
|
||||
**Architecture:** Three independent workstreams executed by subagents. SEC-36 is a strictly ordered cutover (pre-stage hosts → flip GLAuth → verify → finalize) with secret-hygiene rules. TST-30 is infra work on docker host 10.100.0.35 plus a concurrency verification. Publish runs the existing guarded `pack-clients.ps1 -Publish` + `tag-go-module.ps1` locally on macOS.
|
||||
|
||||
**Tech Stack:** ssh (BatchMode works to 10.100.0.35 and 10.100.0.48), PowerShell/nssm on windev, docker compose on 10.100.0.35, Gitea API (`~/.zshenv` has admin-scoped `GITEA_USERNAME`/`GITEA_TOKEN`), pwsh 7 on macOS.
|
||||
|
||||
---
|
||||
|
||||
## Preflight facts (verified 2026-08-07 from this macOS box)
|
||||
|
||||
- `ssh 10.100.0.35` OK. GLAuth container is **`zb-shared-glauth`**, compose working dir **`/home/dohertj2/zb-glauth`** (NOT the runbook's `~/Desktop/scadaproj/infra/glauth` — that path does not exist on the host; the runbook must be corrected in Task 5). Runner container **`gitea-runner`**, compose working dir **`/opt/gitea`**.
|
||||
- `ssh 10.100.0.48` (windev) OK; `powershell -NoProfile` works; `nssm` at `C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Links\nssm.exe`.
|
||||
- `wonder-app-vd03` does NOT resolve from macOS — check it from windev (Task 2).
|
||||
- Gitea API: token valid (`/api/v1/user` → 200), admin (`/api/v1/admin/users` → 200, `POST /api/v1/admin/actions/runners/registration-token` → 200).
|
||||
- Local `~/Desktop/scadaproj/infra/glauth/config.toml` exists (14 `passsha256` entries) — the git source of truth.
|
||||
- `pwsh` at `/usr/local/bin/pwsh`.
|
||||
|
||||
## Secret hygiene (SEC-36, binding for every task)
|
||||
|
||||
- The new plaintext password lives ONLY in `$SECRET_FILE = /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret` (chmod 600), created in Task 1 and shredded in Task 5.
|
||||
- **Never echo/cat the plaintext to stdout, never put it in a commit, a repo file, a log line, or a command whose text is captured verbatim.** Always load it into a shell variable from the file (`val=$(cat "$SECRET_FILE")`) and pass it via stdin or remote-side expansion, never inline in an `ssh "...literal..."` string where avoidable.
|
||||
- The `passsha256` hash MAY appear in `config.toml` commits — that is the established pattern (14 existing entries).
|
||||
- The OLD password must never be printed either. Its only uses are: GLAuth keeps honoring it until Task 4, and the single old-bind-must-fail probe in Task 4.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: SEC-36 — Generate secret, stage GLAuth config change (repo + host copy diff)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 6, Task 9
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/Desktop/scadaproj/infra/glauth/config.toml` (the `serviceaccount` user's `passsha256`) — DO NOT commit yet (Task 5 commits)
|
||||
- Create: `$SECRET_FILE` (scratchpad, chmod 600)
|
||||
|
||||
**Step 1: Generate the new secret and its hash**
|
||||
|
||||
```bash
|
||||
SECRET_FILE="/private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret"
|
||||
umask 077
|
||||
openssl rand -base64 24 | tr -d '\n' > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
NEW_SHA=$(cat "$SECRET_FILE" | tr -d '\n' | shasum -a 256 | awk '{print $1}')
|
||||
echo "$NEW_SHA" # hash only — safe to display
|
||||
```
|
||||
Cross-check the hash recipe against `glauth.md` ("Generate `passsha256` from a plaintext password") in this repo and follow that recipe if it differs.
|
||||
|
||||
**Step 2: Diff host deployment config vs repo source of truth**
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.35 'cat /home/dohertj2/zb-glauth/config.toml' > /tmp/host-glauth-config.toml 2>/dev/null || true
|
||||
diff ~/Desktop/scadaproj/infra/glauth/config.toml /tmp/host-glauth-config.toml
|
||||
```
|
||||
Small drift (comments, ports) is fine — note it. If the `serviceaccount` stanza differs structurally, STOP and surface before editing.
|
||||
|
||||
**Step 3: Edit the repo source of truth**
|
||||
|
||||
In `~/Desktop/scadaproj/infra/glauth/config.toml`, replace the `passsha256` value of the `[[users]]` entry whose `name`/`cn` is `serviceaccount` with `$NEW_SHA`. Edit ONLY that line. Do not `docker compose up` anything yet.
|
||||
|
||||
**Step 4: Record findings**
|
||||
|
||||
Report: hash staged (show hash, never plaintext), drift summary from step 2, and confirm `$SECRET_FILE` exists with mode 600.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SEC-36 — Determine wonder-app-vd03 LDAP status (via windev)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 6, Task 9
|
||||
|
||||
**Step 1: Try to reach vd03 from windev**
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.48 'powershell -NoProfile -Command "Test-Connection wonder-app-vd03 -Count 1 -Quiet"'
|
||||
```
|
||||
|
||||
**Step 2: If reachable, read its gateway config for `MxGateway:Ldap:Enabled`**
|
||||
|
||||
Try (in order, stop at first success): `ssh` hop from windev; reading `\\wonder-app-vd03\c$\...` appsettings/environment via PowerShell remoting (`Invoke-Command -ComputerName wonder-app-vd03`); or `nssm get MxAccessGw AppEnvironmentExtra` remotely. Look for `MxGateway__Ldap__Enabled` / appsettings `Ldap:Enabled`.
|
||||
|
||||
**Step 3: Decide and record**
|
||||
|
||||
- `Enabled=false` or host unreachable/no gateway service → vd03 is OUT of scope; record why (runbook says its dashboard is disabled — `false` is the expected answer).
|
||||
- `Enabled=true` → vd03 is IN scope for Task 3 pre-staging; record the connection method that worked.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: SEC-36 — Pre-stage the NEW value on LDAP-enabled deployed hosts
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (blocked by Tasks 1, 2)
|
||||
|
||||
**Step 1: Pre-stage windev (10.100.0.48)**
|
||||
|
||||
Load the secret locally, then set the env var remotely without leaking it into logged command text more than unavoidable (ssh arguments are not logged remotely by default; do NOT echo the value):
|
||||
|
||||
```bash
|
||||
SECRET_FILE="/private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret"
|
||||
val=$(cat "$SECRET_FILE")
|
||||
ssh 10.100.0.48 'powershell -NoProfile -Command "$v = [Console]::In.ReadLine(); $cur = (& nssm get MxAccessGw AppEnvironmentExtra) -join \"`n\"; Write-Output (\"CURRENT: \" + ($cur -replace \"Password=.*\", \"Password=<redacted>\")); & nssm set MxAccessGw AppEnvironmentExtra (\"MxGateway__Ldap__ServiceAccountPassword=\" + $v)"' <<< "$val"
|
||||
```
|
||||
**CAUTION:** `nssm set AppEnvironmentExtra` REPLACES the whole extra-environment block. First inspect `nssm get MxAccessGw AppEnvironmentExtra` (redacting any `Password=` values); if other variables exist, preserve them in the new value (newline-separated). Adapt quoting as needed — verify with a redacted `nssm get` afterwards.
|
||||
|
||||
**Step 2: Restart the service**
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.48 'nssm restart MxAccessGw'
|
||||
```
|
||||
Expected: service restarts. Binds against GLAuth now fail (old directory, new client value) — expected and brief; proceed immediately to Task 4.
|
||||
|
||||
**Step 3: vd03 (only if Task 2 said IN scope)** — same pre-stage + restart via the method Task 2 found.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SEC-36 — Rotate GLAuth and verify end-to-end
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by Task 3)
|
||||
|
||||
**Step 1: Back up current host config, sync the staged config, recreate**
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.35 'cp /home/dohertj2/zb-glauth/config.toml /home/dohertj2/zb-glauth/config.toml.bak-sec36'
|
||||
scp ~/Desktop/scadaproj/infra/glauth/config.toml 10.100.0.35:/home/dohertj2/zb-glauth/config.toml
|
||||
```
|
||||
**If Task 1's diff showed host-vs-repo drift beyond the serviceaccount line:** do NOT wholesale-copy — instead edit only the serviceaccount `passsha256` line in the host copy (sed on the host), so unrelated host-local drift is preserved.
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.35 'cd /home/dohertj2/zb-glauth && docker compose up -d --force-recreate && sleep 3 && docker compose logs --tail 30'
|
||||
```
|
||||
Expected: clean startup, no TOML parse error. On parse error: restore `.bak-sec36`, recreate, STOP, surface.
|
||||
|
||||
**Step 2: Verify new credential binds (from the glauth host, ldapsearch or python)**
|
||||
|
||||
```bash
|
||||
SECRET_FILE=".../sec36-new-secret" # full scratchpad path
|
||||
val=$(cat "$SECRET_FILE")
|
||||
ssh 10.100.0.35 'ldapsearch -x -H ldap://localhost:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w "$(cat -)" -b "dc=zb,dc=local" "(cn=multi-role)" cn' <<< "$val"
|
||||
```
|
||||
Expected: search returns the `multi-role` entry. (If ldapsearch is missing on the host, run the equivalent from macOS against `10.100.0.35:3893`, or use `docker exec`.) Adjust the bind DN to match the actual `serviceaccount` DN in config.toml.
|
||||
|
||||
**Step 3: Verify the OLD value is dead — exactly ONE probe, from 10.100.0.35 itself**
|
||||
|
||||
One deliberately failing bind with the old password must return invalid credentials. **Only one attempt** (3-fail/10-min per-IP lockout; never probe from a shared-NAT box). The old value: recover it transiently from `config.toml.bak-sec36`'s hash? No — hash is not the plaintext. Instead: skip the plaintext probe if the old plaintext is not already known out-of-band; the hash replacement in config.toml is itself proof GLAuth no longer honors the old value (GLAuth compares against `passsha256` only). Record that reasoning instead of probing blind.
|
||||
|
||||
**Step 4: Verify dashboard login end-to-end on windev**
|
||||
|
||||
```bash
|
||||
curl -sk -o /dev/null -w '%{http_code}' -c /tmp/mxgw-cookies.txt https://10.100.0.48:5001/login
|
||||
```
|
||||
Find the actual dashboard port from windev config first (`nssm get`/appsettings; likely https). Then POST the login form as `multi-role`/`password` (the GLAuth TEST USER password, not the service account) and expect a redirect + `__Host-MxGatewayDashboard` (or `MxGatewayDashboard`) cookie:
|
||||
|
||||
```bash
|
||||
curl -sk -o /dev/null -w '%{http_code}\n' -b /tmp/mxgw-cookies.txt -c /tmp/mxgw-cookies.txt -d 'username=multi-role&password=password' <dashboard-base>/login
|
||||
grep -i mxgatewaydashboard /tmp/mxgw-cookies.txt
|
||||
```
|
||||
Inspect the login page HTML first for real form field names / antiforgery token; adapt. A successful `multi-role` login proves the service-account search bind works with the new credential end-to-end. If HTTP verification proves impractical (antiforgery), fall back to grepping the gateway log on windev for a successful LDAP bind/login line after attempting — or run the live-LDAP integration test from macOS:
|
||||
|
||||
```bash
|
||||
export MXGATEWAY_RUN_LIVE_LDAP_TESTS=1
|
||||
export MxGateway__Ldap__ServiceAccountPassword="$(cat "$SECRET_FILE")"
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
|
||||
```
|
||||
Expected: green. (This binds from macOS to 10.100.0.35:3893 directly — it verifies the credential, and the curl/log check verifies windev.)
|
||||
|
||||
**Step 5: Rollback (only on failure)** — restore `.bak-sec36` on the host, `docker compose up -d --force-recreate`, re-point windev's env var back (old value from where it was before — if unknown, STOP and surface), `nssm restart MxAccessGw`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: SEC-36 — Finalize: commit source of truth, dev secrets, runbook fix, tracker, cleanup
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by Task 4)
|
||||
|
||||
**Step 1: Commit and push the scadaproj glauth change (glauth paths ONLY)**
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/scadaproj
|
||||
git add infra/glauth/config.toml
|
||||
git commit -m "sec(glauth): rotate serviceaccount passsha256 (mxaccessgw SEC-36)"
|
||||
git push
|
||||
```
|
||||
(`scadaproj` is a shared monorepo — stage only this path. If the worktree has unrelated staged changes, use `git commit -- infra/glauth/config.toml` style isolation.)
|
||||
|
||||
**Step 2: Set dev user-secrets on this macOS box**
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
cat "$SECRET_FILE" | tr -d '\n' | dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
|
||||
```
|
||||
(Check `dotnet user-secrets set -h` for stdin support; if unsupported, pass via `"$(cat "$SECRET_FILE")"` — acceptable, it's a local process arg.)
|
||||
|
||||
**Step 3: Correct the runbook + flip tracker rows (mxaccessgw repo)**
|
||||
|
||||
- `docs/runbooks/SEC-36-ldap-credential-rotation.md`: fix the host deployment path (`/home/dohertj2/zb-glauth`, container `zb-shared-glauth`; repo source of truth remains `scadaproj/infra/glauth/`), and note vd03's actual status per Task 2.
|
||||
- Grep `archreview/2026-07-12/remediation/` for SEC-36 pending-operator rows; flip to Done citing the runbook + today's date.
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
grep -rn "SEC-36" archreview/2026-07-12/remediation/ docs/ | grep -iv binary
|
||||
# edit the rows, then:
|
||||
git add -A docs archreview && git commit -m "docs(sec-36): record live rotation done; correct runbook host paths"
|
||||
```
|
||||
|
||||
**Step 4: Shred the secret file**
|
||||
|
||||
```bash
|
||||
rm -P "$SECRET_FILE" 2>/dev/null || rm "$SECRET_FILE"
|
||||
```
|
||||
|
||||
**Step 5: Done-criteria check** — walk the runbook's Done criteria list; report each as met/not-met.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: TST-30 — Recon existing runner config on 10.100.0.35
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 9
|
||||
|
||||
**Step 1: Inspect the existing runner**
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.35 'cat /opt/gitea/docker-compose.yml 2>/dev/null || sudo cat /opt/gitea/docker-compose.yml; ls /opt/gitea'
|
||||
ssh 10.100.0.35 'docker inspect gitea-runner --format "{{json .Mounts}}"; docker exec gitea-runner cat /config.yaml 2>/dev/null || true'
|
||||
```
|
||||
Find: image/version, config file location (look for `container.network: traefik` and `capacity`/`maxParallel`), data volume, registration state file, docker socket mount, labels.
|
||||
|
||||
**Step 2: Check host capacity**
|
||||
|
||||
```bash
|
||||
ssh 10.100.0.35 'nproc; free -h; df -h / | tail -1'
|
||||
```
|
||||
|
||||
**Step 3: Decide (a)-variant** — second container vs raising `capacity` on the existing runner. Runbook prefers a second instance; if the existing runner's config shows a simple `capacity: 1` and resources are tight, raising capacity is the smaller change — but a second registered instance is the runbook default and survives one-runner wedge. Record the chosen variant, the exact compose/config snippets to reuse, and where the registration token goes.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: TST-30 — Register and start the second runner
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by Task 6)
|
||||
|
||||
**Step 1: Mint an instance-level registration token**
|
||||
|
||||
```bash
|
||||
source ~/.zshenv
|
||||
curl -s -X POST -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/admin/actions/runners/registration-token'
|
||||
```
|
||||
(Returns `{"token": "..."}` — a registration token, not a secret credential of lasting value; still avoid committing it.)
|
||||
|
||||
**Step 2: Create the second runner instance per Task 6's plan**
|
||||
|
||||
E.g. add a `gitea-runner-2` service to the compose (distinct name + data volume, same image, same `container.network: traefik`, same socket mount), inject the token via the runner's registration env (`GITEA_RUNNER_REGISTRATION_TOKEN`) or `act_runner register --no-interactive`, then `docker compose up -d gitea-runner-2` from `/opt/gitea`. Back up the compose file first (`cp docker-compose.yml docker-compose.yml.bak-tst30`). Do NOT touch the existing `gitea-runner` service definition.
|
||||
|
||||
**Step 3: Confirm both runners online**
|
||||
|
||||
```bash
|
||||
curl -s -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/admin/actions/runners' | python3 -m json.tool
|
||||
```
|
||||
Expected: ≥2 runners, both online. Also check `docker logs` of the new container for a clean registration + poll loop.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: TST-30 — Verify concurrency, gitea:3000 resolution, tracker
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocked by Task 7)
|
||||
|
||||
**Step 1: Trigger two concurrent runs**
|
||||
|
||||
Push two scratch branches to `mxaccessgw` back-to-back (empty commits off `main`, branch names `scratch/tst30-a`, `scratch/tst30-b`):
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
git push origin main:refs/heads/scratch/tst30-a
|
||||
git commit --allow-empty -m "tst30 concurrency probe" && git push origin HEAD:refs/heads/scratch/tst30-b && git reset --hard HEAD~1
|
||||
```
|
||||
(Adapt: any two pushes that fan out jobs. Clean up branches after: `git push origin :scratch/tst30-a :scratch/tst30-b`.)
|
||||
|
||||
**Step 2: Confirm parallel execution**
|
||||
|
||||
Poll the runs API/UI: the second run's jobs must START before the first run finishes.
|
||||
|
||||
```bash
|
||||
curl -s -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/repos/dohertj2/mxaccessgw/actions/tasks' | python3 -m json.tool | head -60
|
||||
```
|
||||
|
||||
**Step 3: Confirm `gitea:3000` resolves on the new runner** — verify a job scheduled on runner-2 succeeds at checkout (checkout hits `gitea:3000` over the traefik network); identify which runner took each job from the runs UI/API or runner logs.
|
||||
|
||||
**Step 4: Flip TST-30 tracker rows** in `archreview/2026-07-12/remediation/` (grep `TST-30`) to Done with today's date; confirm `docs/GatewayTesting.md` prose is still accurate (it should be — it already describes the bypass as valid regardless of runner count). Commit.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Publish — Preflight audit (versions, registry collisions, toolchains)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1, Task 2, Task 6
|
||||
|
||||
**Step 1: Audit source versions**
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
grep -n 'version' clients/rust/Cargo.toml | head -5
|
||||
grep -n 'version' clients/python/pyproject.toml clients/python/src/zb_mom_ww_mxgateway/version.py
|
||||
grep -n 'ClientVersion' clients/go/mxgateway/version.go
|
||||
grep -n '<Version>' clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
||||
grep -n 'version' clients/java/build.gradle | head -5
|
||||
grep -rn 'CLIENT_VERSION' clients/java --include=*.java | grep -i mxgatewayclientversion
|
||||
```
|
||||
Expected: Rust/Python/Go/.NET/Contracts = 0.2.0; Java build.gradle AND `MxGatewayClientVersion.CLIENT_VERSION` = 0.2.1. Any mismatch → STOP, surface (do not bump versions yourself; that's a scope change).
|
||||
|
||||
**Step 2: Query live registry for collisions**
|
||||
|
||||
```bash
|
||||
source ~/.zshenv
|
||||
for u in 'nuget/ZB.MOM.WW.MxGateway.Client/0.2.0' 'nuget/ZB.MOM.WW.MxGateway.Contracts/0.2.0' 'pypi/zb-mom-ww-mxaccess-gateway-client/0.2.0' 'cargo/zb-mom-ww-mxgateway-client/0.2.0' 'maven/com.zb.mom.ww.mxgateway-zb-mom-ww-mxgateway-client/0.2.1'; do
|
||||
echo "$u => $(curl -s -o /dev/null -w '%{http_code}' -u "$GITEA_USERNAME:$GITEA_TOKEN" "https://gitea.dohertylan.com/api/v1/packages/dohertj2/$u")"
|
||||
done
|
||||
```
|
||||
Expected: 404 for every target (unclaimed). Check the exact maven path convention against `pack-clients.ps1`'s own guard code and use its convention. 200 anywhere → STOP, surface.
|
||||
|
||||
**Step 3: Toolchain + workspace check**
|
||||
|
||||
```bash
|
||||
git -C ~/Desktop/MxAccessGateway status --porcelain # must be clean (publish from a clean tree at origin/main)
|
||||
for t in dotnet cargo go python3 gradle pwsh; do which $t; done
|
||||
```
|
||||
Also confirm `clients/go` module tag `clients/go/v0.2.0` does NOT already exist: `git ls-remote --tags origin 'clients/go/v*'`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Publish — Run the guarded pack-and-publish
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min dispatch (script runtime longer)
|
||||
**Parallelizable with:** none (blocked by Task 9)
|
||||
|
||||
**Step 1: Run pack-clients with publish**
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
source ~/.zshenv
|
||||
pwsh -NoProfile -File scripts/pack-clients.ps1 -Publish 2>&1 | tee /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/pack-clients-publish.log
|
||||
```
|
||||
Expected: per-language build+test+pack, collision guard prints "safe to publish" per artifact, uploads succeed. Timeout generously (Bash timeout 600000). If any language fails MID-loop, record exactly which artifacts pushed and which didn't — partial publish is the known failure mode; do not re-run blindly (re-run is safe only because the guard skips? NO — the guard ABORTS on existing versions. A re-run after partial publish will abort on the already-pushed artifact. If that happens, surface with the log; per-language `-Languages` selective re-run is the fix).
|
||||
If macOS cannot build a language (e.g. gradle/java env), use `-Languages` to publish what builds and surface the remainder — do not fake success.
|
||||
|
||||
**Step 2: Verify each artifact now exists (200)** — re-run Task 9 step 2's loop; expected 200 everywhere published.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Publish — Go module tag
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 10 (blocked by Task 9)
|
||||
|
||||
**Step 1: Tag via the guarded script**
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
pwsh -NoProfile -File scripts/tag-go-module.ps1 -Version 0.2.0
|
||||
```
|
||||
Read the script's param block first (`-Version` name may differ; it validates semver and that `version.go` matches, then creates+pushes `clients/go/v0.2.0`). Expected: tag created and pushed to origin.
|
||||
|
||||
**Step 2: Verify**
|
||||
|
||||
```bash
|
||||
git ls-remote --tags origin 'clients/go/v0.2.0*'
|
||||
```
|
||||
Expected: exactly one tag. Optionally `GOPROXY=direct go list -m gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.2.0` from a temp dir.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Publish — Docs/tracker closeout
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (blocked by Tasks 10, 11)
|
||||
|
||||
**Step 1:** Update `docs/ClientPackaging.md`'s versioning narrative if it claims 0.2.0/0.2.1 are unpublished (it currently records the maven 0.2.1 exception; add a dated line that 0.2.0 (Java 0.2.1) published on 2026-08-07). Grep `archreview/2026-07-12/remediation/` for publish/CLI-39 pending-operator rows and flip to Done.
|
||||
|
||||
**Step 2:** Commit:
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/MxAccessGateway
|
||||
git add docs archreview && git commit -m "docs(clients): record 0.2.0/0.2.1 publish + close operator actions"
|
||||
```
|
||||
|
||||
**Step 3:** Report the full publish matrix (artifact → version → registry HTTP status).
|
||||
|
||||
---
|
||||
|
||||
## Dependency graph
|
||||
|
||||
```
|
||||
{T1, T2} ──▶ T3 ──▶ T4 ──▶ T5 (SEC-36, strictly serial after recon)
|
||||
T6 ──▶ T7 ──▶ T8 (TST-30)
|
||||
T9 ──▶ {T10, T11} ──▶ T12 (Publish)
|
||||
```
|
||||
The three streams are mutually independent and run concurrently. All subagents run with model=opus per operator instruction.
|
||||
|
||||
## Out of scope (explicitly)
|
||||
|
||||
- Option (b)/(c) runner topologies and the `concurrency:` ci.yml experiment (TST-30 runbook marks them escalation/optional).
|
||||
- The five next-cycle candidate findings in `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md`.
|
||||
- Any client version bumps (versions are already landed; a mismatch is a STOP-and-surface).
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-07-live-actions-sec36-tst30-publish.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: SEC-36 — Generate secret, stage GLAuth config change", "status": "completed"},
|
||||
{"id": 2, "subject": "Task 2: SEC-36 — Determine wonder-app-vd03 LDAP status via windev", "status": "completed"},
|
||||
{"id": 3, "subject": "Task 3: SEC-36 — Pre-stage NEW value on LDAP-enabled hosts (nssm + restart)", "status": "completed", "blockedBy": [1, 2]},
|
||||
{"id": 4, "subject": "Task 4: SEC-36 — Rotate GLAuth and verify end-to-end", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: SEC-36 — Finalize: commit, dev secrets, runbook fix, tracker, cleanup", "status": "completed", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: TST-30 — Recon existing runner config on 10.100.0.35", "status": "completed"},
|
||||
{"id": 7, "subject": "Task 7: TST-30 — Register and start the second runner", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 8: TST-30 — Verify concurrency + gitea:3000 + tracker", "status": "completed", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: Publish — Preflight audit (versions, collisions, toolchains)", "status": "completed"},
|
||||
{"id": 10, "subject": "Task 10: Publish — Run pack-clients.ps1 -Publish", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: Publish — Go module tag clients/go/v0.2.0", "status": "completed", "blockedBy": [9]},
|
||||
{"id": 12, "subject": "Task 12: Publish — Docs/tracker closeout", "status": "completed", "blockedBy": [10, 11]}
|
||||
],
|
||||
"lastUpdated": "2026-08-07 (all tasks executed; 4 closeout commits local on main, not pushed)"
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
# WriteSecured Completion Correlation — Design
|
||||
|
||||
Date: 2026-08-09
|
||||
Requested by: OtOpcUa (archreview finding 06/S-1, cross-repo residual — gateway half)
|
||||
Status: approved for implementation (peer contract confirmed over cross-session message)
|
||||
|
||||
## Problem
|
||||
|
||||
The unary `Invoke` reply for a `WriteSecured` / `WriteSecured2` command proves worker-side
|
||||
command *acceptance* only. MXAccess writes are fire-and-forget at the toolkit level: the
|
||||
real per-item outcome arrives later in the `OnWriteComplete` COM callback. Today the worker
|
||||
returns `CreateOkReply` immediately after the COM call, so `MxCommandReply.statuses` is
|
||||
always empty and consumers (OtOpcUa's `GatewayGalaxyDataWriter.TranslateReply`) must treat
|
||||
every write as provisionally good (`galaxy.writes.unconfirmed` meter).
|
||||
|
||||
Consumer contract (already shipped OtOpcUa-side): `statuses.Count > 0` → map `statuses[0]`
|
||||
through the MX status map to a real OPC UA StatusCode; empty → provisional Good +
|
||||
unconfirmed meter. The `statuses` field already exists on `MxCommandReply` (field 7); no
|
||||
proto shape change is needed.
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
1. **Executor pump-wait + versioned completion cache (chosen).** After the `WriteSecured`
|
||||
COM call, the executor holds the STA thread but explicitly pumps Windows messages each
|
||||
poll iteration until the matching completion is recorded or a bounded deadline passes.
|
||||
This is exactly the shipped `ReadBulk` pattern (`MxAccessValueCache.TryWaitForUpdate` +
|
||||
`pumpStep` → `StaRuntime.PumpPendingMessages()`), so it adds no new threading model.
|
||||
2. **Parked asynchronous reply.** Executor returns a "pending" marker; the dispatcher parks
|
||||
the correlation and the pipe reply is written later from the event dispatch. Rejected:
|
||||
changes the `IStaCommandExecutor`/dispatcher/pipe contracts for no real gain — commands
|
||||
serialize per session anyway, so freeing the STA during the wait buys nothing.
|
||||
3. **Gateway-side correlation.** Gateway watches the session event stream for the
|
||||
`OnWriteComplete` after the worker reply. Rejected: races the event drain cadence,
|
||||
couples the gateway to event semantics, still holds the unary RPC, and spreads the
|
||||
feature across two processes.
|
||||
|
||||
## Design
|
||||
|
||||
All changes are worker-side (`ZB.MOM.WW.MxGateway.Worker`, net48 x86). The gateway's
|
||||
`Invoke` already forwards the worker `MxCommandReply` (statuses included) verbatim.
|
||||
|
||||
### New: `MxAccessWriteCompletionCache`
|
||||
|
||||
Mirror of `MxAccessValueCache`, keyed by `(serverHandle, itemHandle)` (packed long), one
|
||||
entry per key holding the most recent completion's `RepeatedField<MxStatusProxy>` (cloned)
|
||||
plus a monotonically increasing per-key `Version`. API:
|
||||
|
||||
- `Record(int serverHandle, int itemHandle, RepeatedField<MxStatusProxy> statuses)`
|
||||
- `ulong CurrentVersion(int serverHandle, int itemHandle)` — 0 when absent
|
||||
- `bool TryWaitForCompletion(int serverHandle, int itemHandle, ulong sinceVersion,
|
||||
DateTime deadlineUtc, Action pumpStep, out RepeatedField<MxStatusProxy> statuses,
|
||||
int pollIntervalMs = 5)` — pump/poll loop identical in shape to
|
||||
`MxAccessValueCache.TryWaitForUpdate`.
|
||||
|
||||
Same locking posture as the value cache: everything runs on the STA thread; a sync root
|
||||
keeps it nominally thread-safe for tests.
|
||||
|
||||
### Sink: record completions
|
||||
|
||||
`MxAccessBaseEventSink` owns a `MxAccessWriteCompletionCache` (new optional ctor param,
|
||||
exposed as a property) and its `OnWriteComplete` handler records into it via the existing
|
||||
`EnqueueEvent` post-publish hook (same pattern as the value cache on `OnDataChange`):
|
||||
the streamed `MxEvent` is built exactly once by the mapper, enqueued unchanged for the
|
||||
event stream, and its `Statuses` are then recorded into the cache. The event stream is
|
||||
not altered — nothing is swallowed or synthesized.
|
||||
|
||||
A new seam interface `IWriteCompletionCacheProvider { MxAccessWriteCompletionCache
|
||||
WriteCompletionCache { get; } }` is implemented by `MxAccessBaseEventSink` and by test
|
||||
sinks. `MxAccessSession.Create` pulls the cache from the sink through that interface
|
||||
(fallback: fresh instance), mirroring the existing `ValueCache` sharing, and exposes it
|
||||
on the session.
|
||||
|
||||
### Executor: bounded pump-wait
|
||||
|
||||
In `ExecuteWriteSecured` / `ExecuteWriteSecured2`:
|
||||
|
||||
1. Capture `baseline = cache.CurrentVersion(serverHandle, itemHandle)` **before** the COM
|
||||
call — this closes the fast-completion ordering edge: a callback that dispatches during
|
||||
or immediately after the COM call bumps the version past the baseline and still
|
||||
correlates.
|
||||
2. Call `session.WriteSecured(...)` as today.
|
||||
3. `cache.TryWaitForCompletion(..., baseline, deadline, pumpStep, out statuses)`; on
|
||||
success, `reply.Statuses.Add(statuses)`; on timeout, return the reply exactly as today
|
||||
(protocol OK, empty statuses) — the consumer's honest-unconfirmed path. No invented
|
||||
failure rows.
|
||||
|
||||
The reply's `ProtocolStatus`/`Hresult` stay untouched by the completion outcome: the
|
||||
command was accepted; the MX outcome (success *or* failure) is carried only in
|
||||
`statuses[0]`. That preserves MXAccess parity (the native API returns void; the outcome
|
||||
exists only in the callback) while enriching the reply with information that was already
|
||||
on the wire.
|
||||
|
||||
Timeout default: **1.5 s** (`MxAccessCommandExecutor.DefaultWriteCompletionTimeout`).
|
||||
The consumer-side budget drives this: OtOpcUa wraps the driver write in a Tier A
|
||||
resilience policy with a 2 s timeout and a 5-failure breaker, so a worker wait longer
|
||||
than 2 s would convert slow-but-successful commits into consumer-side false failures
|
||||
(node revert + breaker pressure). 1.5 s covers the common fast-commit case and degrades
|
||||
a slow commit to the honest-unconfirmed path instead. It also sits well under the
|
||||
gateway's 30 s `DefaultCommandTimeoutSeconds` IPC wait and the STA watchdog's 75 s
|
||||
dispatched-command ceiling.
|
||||
|
||||
The wait is deployment-configurable end to end, following the existing
|
||||
pipe-connect-timeout pattern: a new gateway option
|
||||
`MxGateway:Worker:WriteCompletionWaitMilliseconds` (default `1500`, validated `>= 0`;
|
||||
`0` disables the wait and restores pure fire-and-forget replies) is exported by
|
||||
`WorkerProcessLauncher` as the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment
|
||||
variable, which the worker reads at session construction. A deployment that raises the
|
||||
gateway wait must raise the OtOpcUa driver's Write `ResilienceConfig` timeout in step
|
||||
(per-instance operator config on the OtOpcUa side) — documented in
|
||||
`docs/GatewayConfiguration.md`. `MxAccessStaSession` additionally gets an internal
|
||||
`WriteCompletionTimeout` seam (read when it constructs the executor in `StartAsync`) so
|
||||
tests can shorten it without env plumbing.
|
||||
|
||||
Client cancellation mid-wait needs no new code path, only verification: when the caller
|
||||
cancels the unary RPC, the gateway abandons its IPC reply wait, but the worker command
|
||||
is already in flight — `CancelCommand` only dequeues *queued* commands. The executor
|
||||
simply finishes its bounded wait and replies; the gateway discards the reply. The
|
||||
session is never faulted and the `OnWriteComplete` event still flows on the stream.
|
||||
|
||||
### Scope
|
||||
|
||||
- **In**: `WriteSecured`, `WriteSecured2` (single-item secured writes — inherently
|
||||
low-rate operator actions, and exactly the OtOpcUa single-write contract). Default-on.
|
||||
- **Out**: plain `Write`/`Write2` and all bulk write commands stay fire-and-forget —
|
||||
waiting would add a device round-trip of latency to high-rate supervisory write loops.
|
||||
- **Correlation fidelity is best-effort**: the MXAccess callback carries only
|
||||
`(hItem, statuses)` — no transaction id — so a concurrent write to the same item within
|
||||
the wait window can be attributed to the wrong writer (worst case two writes to the
|
||||
same item swap status rows — benign for the serialized single-write consumer
|
||||
contract). Documented on the proto field.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Completion never arrives (device down): bounded 1.5 s wait, then today's reply shape.
|
||||
- Event queue overflow during completion: the queue records a fault and the fail-fast
|
||||
design tears the session down; the post-publish hook not firing in that case is moot.
|
||||
- COM call throws: unchanged — the dispatcher's existing exception path replies with the
|
||||
native HResult; no wait is entered.
|
||||
|
||||
## Testing (Worker.Tests, x86 — verified on windev)
|
||||
|
||||
- `MxAccessWriteCompletionCacheTests`: record/version monotonicity, wait success,
|
||||
deadline expiry, baseline-before-record fast-completion ordering.
|
||||
- `MxAccessBaseEventSinkTests`: `OnWriteComplete` both enqueues the event *and* records
|
||||
the completion; cache instance is the sink-bound one.
|
||||
- `MxAccessCommandExecutorTests` (via `MxAccessStaSession.DispatchAsync` + fake COM
|
||||
object + test sink implementing `IWriteCompletionCacheProvider`):
|
||||
- completion recorded synchronously inside the fake's `WriteSecured` (fast edge) →
|
||||
reply carries `statuses[0]`;
|
||||
- completion recorded from the test thread while the executor pump-waits → reply
|
||||
carries `statuses[0]`;
|
||||
- no completion + shortened timeout → protocol OK, empty statuses;
|
||||
- `WriteSecured2` mirrors; plain `Write` does not wait.
|
||||
- Gateway tests (macOS-runnable): `GatewayOptionsValidator` accepts `>= 0` and rejects
|
||||
negative `WriteCompletionWaitMilliseconds`; `WorkerProcessLauncher` exports
|
||||
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` from the option (mirroring the existing
|
||||
pipe-connect-timeout env assertion).
|
||||
|
||||
## Docs in the same change
|
||||
|
||||
- `mxaccess_gateway.proto`: comment on `MxCommandReply.statuses` and the
|
||||
`WriteSecuredCommand`/`WriteSecured2Command` messages describing the correlated
|
||||
completion contract (populated within the wait window; empty = unconfirmed;
|
||||
best-effort correlation). Comment-only → wire-identical; regenerate + commit
|
||||
`Contracts/Generated` (required for the C# build); other clients' generated code is
|
||||
functionally unchanged.
|
||||
- `docs/GatewayConfiguration.md`: the new `MxGateway:Worker:WriteCompletionWaitMilliseconds`
|
||||
option, including the pairing rule with the OtOpcUa driver's Write resilience timeout.
|
||||
- `gateway.md` command/event surface note; `docs/DesignDecisions.md` entry.
|
||||
@@ -0,0 +1,575 @@
|
||||
# WriteSecured Completion Correlation Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Populate `MxCommandReply.statuses[0]` on unary `WriteSecured`/`WriteSecured2` replies with the correlated MXAccess `OnWriteComplete` outcome, bounded by a configurable wait (default 1.5 s), falling back to today's empty-statuses shape on timeout.
|
||||
|
||||
**Architecture:** Worker-side only (plus one gateway config option). A versioned per-`(serverHandle, itemHandle)` completion cache (mirror of `MxAccessValueCache`) is populated by the event sink's `OnWriteComplete` post-publish hook; the STA command executor captures a version baseline before the COM call, then pump-waits (ReadBulk precedent) until a newer completion lands or the deadline passes. Design: `docs/plans/2026-08-09-write-completion-correlation-design.md`.
|
||||
|
||||
**Tech Stack:** .NET Framework 4.8 x86 worker (no init-only props/positional records!), .NET 10 gateway, protobuf via Grpc.Tools regen. Worker builds/tests run ONLY on windev (10.100.0.48) — local macOS verification covers the gateway + contracts.
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Create feature branch
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~1 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/MxAccessGateway && git checkout -b feat/write-completion-correlation
|
||||
```
|
||||
|
||||
### Task 1: Proto contract comments + regen
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (everything builds on the regenerated contracts)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (~line 528 `statuses`, ~line 259 `WriteSecuredCommand`, ~line 269 `WriteSecured2Command`)
|
||||
- Regenerate: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs`
|
||||
|
||||
**Step 1:** On `repeated MxStatusProxy statuses = 7;` in `MxCommandReply`, add above the field:
|
||||
|
||||
```proto
|
||||
// Correlated per-item outcome rows. For 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 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
|
||||
// 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
|
||||
// window can swap rows. The OnWriteComplete event still flows on the event
|
||||
// stream unchanged. Other command kinds leave this field as before.
|
||||
```
|
||||
|
||||
**Step 2:** On `message WriteSecuredCommand` and `message WriteSecured2Command`, append to the existing leading comment (or add one): `// The unary reply's statuses field carries the correlated OnWriteComplete outcome when it arrives within the worker's bounded wait — see MxCommandReply.statuses.`
|
||||
|
||||
**Step 3:** Regenerate + verify wire-identical build:
|
||||
|
||||
```bash
|
||||
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
||||
```
|
||||
Expected: build succeeds, `git diff --stat` shows only comment-churn in Generated.
|
||||
|
||||
**Step 4:** Commit: `git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto src/ZB.MOM.WW.MxGateway.Contracts/Generated && git commit -m "docs(proto): document the correlated write-completion statuses contract"`
|
||||
(Comment-only proto change is wire-identical; other clients' generated code is intentionally not regenerated — no functional delta.)
|
||||
|
||||
### Task 2: MxAccessWriteCompletionCache + tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs`
|
||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs`
|
||||
|
||||
**Step 1:** Create the cache — mirror `MxAccessValueCache`'s shape, locking, and net48 constraints (no init-only, plain struct/class):
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Google.Protobuf.Collections;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session cache of the most recent <c>OnWriteComplete</c> status rows
|
||||
/// for each (server handle, item handle) pair. Written by the MXAccess
|
||||
/// event sink as completion callbacks arrive; read by the write command
|
||||
/// executor so a WriteSecured/WriteSecured2 reply can carry the correlated
|
||||
/// MXAccess outcome instead of proving command acceptance only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Same threading posture as <see cref="MxAccessValueCache"/>: writers and
|
||||
/// readers run on the worker's STA thread (COM dispatches events on the
|
||||
/// apartment thread; commands also execute on the STA), so no internal
|
||||
/// locking is required. A single sync root keeps it nominally thread-safe
|
||||
/// for tests that drive it from a non-STA thread.
|
||||
/// </remarks>
|
||||
public sealed class MxAccessWriteCompletionCache
|
||||
{
|
||||
private readonly Dictionary<long, CompletionEntry> entries = new();
|
||||
private readonly object syncRoot = new();
|
||||
|
||||
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <param name="statuses">Status rows from the mapped OnWriteComplete event; cloned before storing.</param>
|
||||
public void Record(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
RepeatedField<MxStatusProxy> statuses)
|
||||
{
|
||||
if (statuses is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(statuses));
|
||||
}
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
ulong version = entries.TryGetValue(key, out CompletionEntry existing)
|
||||
? existing.Version + 1
|
||||
: 1UL;
|
||||
entries[key] = new CompletionEntry(version, statuses.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current completion version for a handle pair, or 0 if none was recorded.</summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <returns>The current completion version, or 0 if no completion was recorded.</returns>
|
||||
public ulong CurrentVersion(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing)
|
||||
? existing.Version
|
||||
: 0UL;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls for a completion newer than <paramref name="sinceVersion"/> until it
|
||||
/// arrives or the deadline elapses, calling <paramref name="pumpStep"/> on every
|
||||
/// poll iteration so the worker's STA can dispatch the inbound MXAccess
|
||||
/// OnWriteComplete message. Same loop shape as
|
||||
/// <see cref="MxAccessValueCache.TryWaitForUpdate"/>.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <param name="sinceVersion">Version snapshot captured before the write COM call.</param>
|
||||
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
|
||||
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
|
||||
/// <param name="statuses">The recorded status rows if a completion arrived before the deadline.</param>
|
||||
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
|
||||
/// <returns><see langword="true"/> if a completion newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
|
||||
public bool TryWaitForCompletion(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
ulong sinceVersion,
|
||||
DateTime deadlineUtc,
|
||||
Action pumpStep,
|
||||
out RepeatedField<MxStatusProxy> statuses,
|
||||
int pollIntervalMs = 5)
|
||||
{
|
||||
if (pumpStep is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(pumpStep));
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
pumpStep();
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry)
|
||||
&& entry.Version > sinceVersion)
|
||||
{
|
||||
statuses = entry.Statuses;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow >= deadlineUtc)
|
||||
{
|
||||
statuses = new RepeatedField<MxStatusProxy>();
|
||||
return false;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private static long CreateItemKey(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return ((long)serverHandle << 32) | (uint)itemHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the most recent OnWriteComplete status rows for a handle
|
||||
/// pair. <see cref="Version"/> increments by one on every
|
||||
/// <see cref="Record"/> call so the write executor can detect "a new
|
||||
/// completion arrived since I captured my baseline".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain readonly struct (not a record) so this compiles under the
|
||||
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
|
||||
/// </remarks>
|
||||
private readonly struct CompletionEntry
|
||||
{
|
||||
public CompletionEntry(
|
||||
ulong version,
|
||||
RepeatedField<MxStatusProxy> statuses)
|
||||
{
|
||||
Version = version;
|
||||
Statuses = statuses;
|
||||
}
|
||||
|
||||
public ulong Version { get; }
|
||||
|
||||
public RepeatedField<MxStatusProxy> Statuses { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** Tests (mirror `MxAccessValueCacheTests` style; build `MxStatusProxy` rows inline):
|
||||
|
||||
- `Record_IncrementsVersionPerKey` — two `Record` calls on the same pair → `CurrentVersion` 1 then 2; a different pair stays independent.
|
||||
- `TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses` — record once, wait with `sinceVersion: 0`, deadline in the future → `true`, statuses round-trip (assert an `MxStatusProxy` field value survives the clone).
|
||||
- `TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut` — record once, wait with `sinceVersion: CurrentVersion(...)` and a deadline ~50 ms out → `false`, out statuses empty.
|
||||
- `TryWaitForCompletion_InvokesPumpStepEachIteration` — pumpStep increments a counter; on the counter's second call, `Record` the completion (this proves the pump loop is what lets the callback land); assert `true` and counter >= 2.
|
||||
- `Record_ClonesStatuses` — mutate the caller's `RepeatedField` after `Record`; waited-out statuses unaffected.
|
||||
|
||||
**Step 3:** Cannot compile locally (worker is Windows-only) — defer build/test to Task 9 (windev). Commit: `git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs && git commit -m "feat(worker): versioned OnWriteComplete completion cache"`
|
||||
|
||||
### Task 3: Sink records completions (+ provider seam)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 7
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs`
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs`
|
||||
|
||||
**Step 1:** New seam interface:
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the per-session <see cref="MxAccessWriteCompletionCache"/> an
|
||||
/// event sink populates from OnWriteComplete callbacks, so
|
||||
/// <see cref="MxAccessSession.Create"/> can share one instance between the
|
||||
/// sink (writer) and the write command executor (reader). Implemented by
|
||||
/// <see cref="MxAccessBaseEventSink"/> and by test sinks that cannot
|
||||
/// attach to a live MXAccess COM object.
|
||||
/// </summary>
|
||||
public interface IWriteCompletionCacheProvider
|
||||
{
|
||||
/// <summary>The completion cache bound to this sink.</summary>
|
||||
MxAccessWriteCompletionCache WriteCompletionCache { get; }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** `MxAccessBaseEventSink` — declare `IWriteCompletionCacheProvider` on the class; add a `private readonly MxAccessWriteCompletionCache writeCompletionCache;` initialized in the widest ctor (add a new optional-most ctor overload following the existing chain pattern: the 3-arg `(eventQueue, eventMapper, valueCache)` ctor chains to a new 4-arg `(eventQueue, eventMapper, valueCache, writeCompletionCache)` with a fresh cache); expose `public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;`. Change `OnWriteComplete` to use the post-publish hook (same pattern as `OnDataChange`'s value-cache publish — post-publish only runs after the event cleared the queue, and a queue overflow faults the session anyway):
|
||||
|
||||
```csharp
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(
|
||||
() => eventMapper.CreateOnWriteComplete(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
statuses),
|
||||
mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses));
|
||||
```
|
||||
|
||||
**Step 3:** Tests in `MxAccessBaseEventSinkTests` (mirror `OnDataChange_ComCallback_PopulatesValueCache` and `ValueCache_ReturnsTheInstanceBoundAtConstruction`):
|
||||
- `OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent` — drive `sink.OnWriteComplete(7, 21, ref proxies)`; assert the queue got the OnWriteComplete event AND `cache.CurrentVersion(7, 21) == 1`.
|
||||
- `WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction`.
|
||||
|
||||
**Step 4:** Commit: `git commit -m "feat(worker): event sink records OnWriteComplete rows into the completion cache"` (explicit paths).
|
||||
|
||||
### Task 4: MxAccessSession plumbing
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 7
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs` (private ctor ~line 18, `Create` ~line 145)
|
||||
|
||||
**Step 1:** Add `private readonly MxAccessWriteCompletionCache writeCompletionCache;` + ctor param (after `valueCache`) + null guard; add property mirroring `ValueCache`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Per-session OnWriteComplete completion cache populated by the event
|
||||
/// sink. The write command executor consults it after a
|
||||
/// WriteSecured/WriteSecured2 COM call so the unary reply can carry
|
||||
/// the correlated completion outcome.
|
||||
/// </summary>
|
||||
public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;
|
||||
```
|
||||
|
||||
**Step 2:** In `Create`, next to the value-cache sharing block:
|
||||
|
||||
```csharp
|
||||
// Share the sink's completion cache the same way (production sink
|
||||
// and completion-aware test sinks implement the provider seam);
|
||||
// fall back to a fresh cache for other fakes — the write executor
|
||||
// then simply never observes a completion and replies unconfirmed.
|
||||
MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider
|
||||
? provider.WriteCompletionCache
|
||||
: new MxAccessWriteCompletionCache();
|
||||
```
|
||||
|
||||
Pass it to the ctor.
|
||||
|
||||
**Step 3:** Commit: `git commit -m "feat(worker): share the completion cache between sink and session"`.
|
||||
|
||||
### Task 5: Executor bounded wait + StaSession env plumbing
|
||||
|
||||
**Classification:** high-risk (STA/pump semantics)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (touches the same files as 6's tests exercise)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (~lines 14-85 ctors, 447-497 write methods)
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (~line 205 executor construction)
|
||||
|
||||
**Step 1:** Executor — next to `DefaultReadBulkTimeout`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Default bounded wait for the OnWriteComplete callback after a
|
||||
/// WriteSecured/WriteSecured2 COM call. 1.5 s keeps the unary reply
|
||||
/// inside the OtOpcUa driver's 2 s Tier A write-resilience budget (a
|
||||
/// longer gateway wait must raise that consumer timeout in step) while
|
||||
/// covering the common fast-commit case; on expiry the reply returns
|
||||
/// with empty statuses — unconfirmed, not failed.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500);
|
||||
|
||||
private readonly TimeSpan writeCompletionTimeout;
|
||||
```
|
||||
|
||||
Add `TimeSpan? writeCompletionTimeout = null` as a trailing optional parameter on the widest (4-arg) ctor; `this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;`.
|
||||
|
||||
**Step 2:** In `ExecuteWriteSecured`, replace the tail (`session.WriteSecured(...); return CreateOkReply(command);`) with:
|
||||
|
||||
```csharp
|
||||
MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache;
|
||||
// Baseline BEFORE the COM call: a completion that dispatches during or
|
||||
// immediately after WriteSecured bumps the version past this snapshot,
|
||||
// so a fast commit still correlates (no missed-callback window).
|
||||
ulong completionBaseline = completionCache.CurrentVersion(
|
||||
writeSecuredCommand.ServerHandle,
|
||||
writeSecuredCommand.ItemHandle);
|
||||
|
||||
session.WriteSecured(
|
||||
writeSecuredCommand.ServerHandle,
|
||||
writeSecuredCommand.ItemHandle,
|
||||
writeSecuredCommand.CurrentUserId,
|
||||
writeSecuredCommand.VerifierUserId,
|
||||
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
|
||||
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
AwaitWriteCompletion(
|
||||
reply,
|
||||
completionCache,
|
||||
writeSecuredCommand.ServerHandle,
|
||||
writeSecuredCommand.ItemHandle,
|
||||
completionBaseline);
|
||||
return reply;
|
||||
```
|
||||
|
||||
Mirror in `ExecuteWriteSecured2`. Shared private helper:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Bounded pump-wait for the OnWriteComplete row matching a
|
||||
/// WriteSecured/WriteSecured2 call, copied onto the reply when it
|
||||
/// arrives in time. The executor holds the STA thread but pumps
|
||||
/// Windows messages each poll (ReadBulk precedent) so the COM callback
|
||||
/// can dispatch re-entrantly; on expiry the reply keeps its empty
|
||||
/// statuses — the consumer's unconfirmed path, never a synthesized
|
||||
/// failure. Protocol status/hresult stay acceptance-only either way.
|
||||
/// </summary>
|
||||
private void AwaitWriteCompletion(
|
||||
MxCommandReply reply,
|
||||
MxAccessWriteCompletionCache completionCache,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
ulong completionBaseline)
|
||||
{
|
||||
if (writeCompletionTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (completionCache.TryWaitForCompletion(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
completionBaseline,
|
||||
DateTime.UtcNow + writeCompletionTimeout,
|
||||
pumpStep,
|
||||
out Google.Protobuf.Collections.RepeatedField<MxStatusProxy> statuses))
|
||||
{
|
||||
reply.Statuses.Add(statuses);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3:** `MxAccessStaSession` — add:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Environment variable the gateway's WorkerProcessLauncher sets from
|
||||
/// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the
|
||||
/// write-completion wait (pure fire-and-forget replies).
|
||||
/// </summary>
|
||||
internal const string WriteCompletionWaitEnvironmentVariableName =
|
||||
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
||||
|
||||
/// <summary>
|
||||
/// Bounded WriteSecured/WriteSecured2 completion wait handed to the
|
||||
/// command executor at StartAsync. Internal-settable as a test seam so
|
||||
/// Worker.Tests can shorten it without env-var plumbing.
|
||||
/// </summary>
|
||||
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
|
||||
|
||||
internal static TimeSpan ResolveWriteCompletionTimeout()
|
||||
{
|
||||
string value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName);
|
||||
return int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int milliseconds)
|
||||
&& milliseconds >= 0
|
||||
? TimeSpan.FromMilliseconds(milliseconds)
|
||||
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
|
||||
}
|
||||
```
|
||||
|
||||
and pass `writeCompletionTimeout: WriteCompletionTimeout` when constructing `MxAccessCommandExecutor` in `StartAsync`. (net48: `Environment.GetEnvironmentVariable` returns `string` — keep nullable annotations consistent with the file.)
|
||||
|
||||
**Step 4:** Commit: `git commit -m "feat(worker): bounded pump-wait correlates OnWriteComplete onto secured-write replies"`.
|
||||
|
||||
### Task 6: Executor tests
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (depends on Tasks 2-5)
|
||||
|
||||
**Files:**
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs`
|
||||
|
||||
**Step 1:** Test support inside the test class file:
|
||||
- New sink: `private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider { public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache(); public void Attach(object mxAccessComObject, string sessionId) { } public void Detach() { } }` (match the exact `IMxAccessEventSink` member list — read the interface first).
|
||||
- `FakeMxAccessComObject`: add `public Action OnWriteSecuredCallback { get; set; }` (nullable per file convention) invoked at the end of `WriteSecured` and `WriteSecured2`.
|
||||
|
||||
**Step 2:** Tests (all through `MxAccessStaSession.DispatchAsync`, constructing the session with the completion sink; set `session.WriteCompletionTimeout` before `StartAsync`):
|
||||
|
||||
- `DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses` (fast-completion edge): wire `OnWriteSecuredCallback = () => sink.WriteCompletionCache.Record(82, 821, StatusRows(1))` (helper building a `RepeatedField<MxStatusProxy>` with a recognizable value); dispatch; assert `ProtocolStatus.Code == Ok`, `reply.Statuses.Count == 1`, row round-trips.
|
||||
- `DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses`: no fake callback; `WriteCompletionTimeout = TimeSpan.FromSeconds(10)`; start `Task<MxCommandReply> pending = session.DispatchAsync(...)`, then `sink.WriteCompletionCache.Record(...)` from the test thread after a short `Task.Delay(50)`; await; assert statuses present. (No `.ConfigureAwait(false)` in `[Fact]` bodies — xUnit1030 fails the Windows build.)
|
||||
- `DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol`: `WriteCompletionTimeout = TimeSpan.FromMilliseconds(100)`; assert Ok + `reply.Statuses.Count == 0`.
|
||||
- `DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses` (mirror of the fast test).
|
||||
- `DispatchAsync_Write_DoesNotWaitForCompletion`: `WriteCompletionTimeout = TimeSpan.FromSeconds(30)`, plain `Write` command, no completion recorded; assert `await` completes within a 5 s guard (`Task.WhenAny` with `Task.Delay`) — proving plain writes never enter the wait.
|
||||
- Baseline test `DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall`: `Record` once BEFORE dispatch, `WriteCompletionTimeout = 100 ms`, no new completion → empty statuses (stale row not misattributed).
|
||||
|
||||
**Step 2b:** Confirm the two existing WriteSecured tests (`DispatchAsync_WriteSecured_ForwardsUserIds`, `..._WriteSecured2_...`) still pass unmodified — they use `NoopEventSink`, so the session falls back to a fresh cache, no completion ever arrives, and the default 1.5 s wait adds latency only; if that latency bothers the suite, switch them to the completion sink with `WriteCompletionTimeout = TimeSpan.Zero`.
|
||||
|
||||
**Step 3:** Commit: `git commit -m "test(worker): write-completion correlation executor coverage"`.
|
||||
|
||||
### Task 7: Gateway config option + launcher env var
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2, Task 3, Task 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/WorkerOptions.cs`
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (~line 224 block)
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs` (~lines 18-22 consts, ~line 175 env block)
|
||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs`, the `GatewayOptionsValidator` test file (find via `grep -rl GatewayOptionsValidatorTests src/ZB.MOM.WW.MxGateway.Tests`)
|
||||
|
||||
**Step 1:** `WorkerOptions`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Bounded wait, in milliseconds, the worker holds a WriteSecured/WriteSecured2
|
||||
/// reply for the matching MXAccess OnWriteComplete callback so the reply's
|
||||
/// statuses carry the real commit outcome. 0 disables the wait. Deployments
|
||||
/// raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A
|
||||
/// write resilience timeout) must raise those in step.
|
||||
/// </summary>
|
||||
public int WriteCompletionWaitMilliseconds { get; init; } = 1500;
|
||||
```
|
||||
|
||||
**Step 2:** Validator (>= 0, not the positive helper):
|
||||
|
||||
```csharp
|
||||
if (options.WriteCompletionWaitMilliseconds < 0)
|
||||
{
|
||||
builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3:** Launcher — const `public const string WorkerWriteCompletionWaitEnvironmentVariableName = "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";` and in `CreateStartInfo` next to the pipe-connect env line:
|
||||
|
||||
```csharp
|
||||
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
|
||||
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
```
|
||||
|
||||
**Step 4:** Tests — mirror the existing pipe-connect-timeout launcher env assertion and an existing validator negative test; add: launcher exports `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS=1500` by default / custom value when configured; validator rejects `-1`, accepts `0`.
|
||||
|
||||
**Step 5:** Run locally:
|
||||
|
||||
```bash
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayOptionsValidator"
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
**Step 6:** Commit: `git commit -m "feat(gateway): configurable worker write-completion wait (MxGateway:Worker:WriteCompletionWaitMilliseconds)"`.
|
||||
|
||||
### Task 8: Docs
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (write after code settles)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/GatewayConfiguration.md` (Worker options table, ~line 113 area)
|
||||
- Modify: `gateway.md` (command surface / write semantics section)
|
||||
- Modify: `docs/DesignDecisions.md` (new decision entry)
|
||||
|
||||
Content: the option row (default 1500, 0 disables, consumer-budget pairing rule with OtOpcUa's Write resilience timeout); gateway.md note that WriteSecured/WriteSecured2 unary replies now carry correlated completion statuses (bounded wait, empty = unconfirmed, event stream unchanged); DesignDecisions entry summarizing the design doc (link it) including the best-effort per-(hItem) correlation caveat and why plain Write/Write2/bulk stay fire-and-forget. Follow `docs/style-guides/StyleGuide.md` (present tense, why not what).
|
||||
|
||||
Commit: `git commit -m "docs: write-completion correlation configuration and semantics"`.
|
||||
|
||||
### Task 9: Local + windev verification
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~10 min (mostly remote build time)
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Step 1 (local):** `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` → succeeds; re-run the Task 7 filtered gateway tests.
|
||||
|
||||
**Step 2 (push):** `git push -u origin feat/write-completion-correlation`.
|
||||
|
||||
**Step 3 (windev):** Use the isolated `C:\build` worktree (NOT the Desktop checkout — dirty feature branch). Remote PS via base64 `-EncodedCommand`; no `$ErrorActionPreference='Stop'` around git. Sequence:
|
||||
|
||||
```
|
||||
git fetch origin && git checkout feat/write-completion-correlation && git pull
|
||||
dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~MxAccessWriteCompletionCacheTests|FullyQualifiedName~MxAccessBaseEventSinkTests|FullyQualifiedName~MxAccessCommandExecutorTests"
|
||||
```
|
||||
Expected: build clean (TreatWarningsAsErrors — watch xUnit1030), all filtered tests PASS. Fix-and-push iterations happen from the Mac; windev only builds/tests.
|
||||
|
||||
**Step 4:** Full worker test suite once green on the filter (`dotnet test ... -p:Platform=x86`, no filter) — one full pass before merge.
|
||||
|
||||
### Task 10: Review, merge, notify
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
- Run a code review over the full branch diff (code-reviewer agent or /code-review) and address findings.
|
||||
- Merge: `git checkout main && git merge --no-ff feat/write-completion-correlation && git push origin main`.
|
||||
- Notify the OtOpcUa session (SendMessage) that the feature is on `main` and built/tested on windev; flag that the live ArchestrA leg needs the user's Windows deployment (wonder-app-vd03 / 10.100.0.48 redeploy is a separate user-approved step).
|
||||
- Surface to the user: deployed services (`MxAccessGw` on 10.100.0.48, wonder-app-vd03) do NOT pick this up until redeployed; OtOpcUa's end-to-end verification against a live gateway needs that redeploy.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-08-09-write-completion-correlation.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Create feature branch", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Proto contract comments + regen", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: MxAccessWriteCompletionCache + tests", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Sink records completions (+ provider seam)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Task 4: MxAccessSession plumbing", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Executor bounded wait + StaSession env plumbing", "status": "pending", "blockedBy": [4]},
|
||||
{"id": 6, "subject": "Task 6: Executor tests", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: Gateway config option + launcher env var", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 9, "subject": "Task 9: Local + windev verification", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: Review, merge, notify", "status": "pending", "blockedBy": [9]}
|
||||
],
|
||||
"lastUpdated": "2026-08-09"
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
# Dashboard UI cleanup sweep (2026-08-11)
|
||||
|
||||
Runs the family admin-UI cleanup playbook (`../scadaproj/admin_ui_cleanup.md`) against the
|
||||
MXAccess Gateway Blazor dashboard. Third app in the family after the ignitionoee router and the
|
||||
OtOpcUa AdminUI.
|
||||
|
||||
## 0. Platform correction — this app is *not* Bootstrap-free
|
||||
|
||||
The umbrella index describes mxaccessgw as the family's Bootstrap-free app. That is wrong, and
|
||||
every Bootstrap-dependent recipe in the playbook applies here unchanged. What actually ships:
|
||||
|
||||
| Layer | Evidence |
|
||||
|---|---|
|
||||
| Bootstrap 5.3.3, self-hosted | `libman.json:5`; `wwwroot/lib/bootstrap/css/bootstrap.min.css` |
|
||||
| Linked first in the head | `Dashboard/Components/App.razor:7` |
|
||||
| `ZB.MOM.WW.Theme` 0.3.1 at discovery, **0.4.0 after §6** | `ZB.MOM.WW.MxGateway.Server.csproj` `<PackageReference>`; `<ThemeHead />` at `App.razor:8` |
|
||||
| App stylesheet, loaded last | `App.razor:9` → `wwwroot/css/site.css` |
|
||||
| Bootstrap JS bundle | `App.razor:14` |
|
||||
|
||||
What CLAUDE.md actually forbids is Blazor **component libraries** (MudBlazor, Radzen, FluentUI) —
|
||||
not Bootstrap's CSS/JS. No Bootstrap was introduced by this sweep.
|
||||
|
||||
Playbook §2 foundation item 1 (*app stylesheet after `<ThemeHead />` so it wins the cascade*) was
|
||||
therefore **already satisfied** before the sweep.
|
||||
|
||||
## 1. Discovery
|
||||
|
||||
### 1a. Foundation / CSS audit
|
||||
|
||||
**Shipped-vs-used class matrix.** Every class in `Dashboard/Components/**/*.razor` checked against
|
||||
theme 0.3.1 `staticwebassets/css/{theme,layout}.css`, `bootstrap.min.css`, and `site.css`. Three
|
||||
classes are defined nowhere:
|
||||
|
||||
| Ghost class | Site | Consequence | Verdict |
|
||||
|---|---|---|---|
|
||||
| `tree-load-status` | `Shared/BrowseTreeNodeView.razor:42`, `:49` | **Real visual defect.** The row's indent spacer is `<span class="tree-toggle tree-toggle-empty">`; `.tree-toggle` sets `flex:none;width:1.1rem`, which only takes effect on a flex item. `.tree-row`/`.tree-attr` are flex; this container is an undefined block, so the span stays inline and `width` is ignored — "⌛ Loading…" and "Failed to load: …" render flush left, out of alignment with every sibling row. | **Define it** |
|
||||
| `browse-stale-banner` | `Pages/BrowsePage.razor:78` | The banner carries `@onclick="ClearStaleBanner"` with no pointer affordance and no styling of its own — a click-to-dismiss control that does not look clickable. | **Define it** |
|
||||
| `tree-node` | `Shared/BrowseTreeNodeView.razor:15` | Structural wrapper only; nothing needs to style it. A no-op, but a deliberate one. | **Leave** |
|
||||
|
||||
(Reported as ghosts by the raw extractor but false positives: `h-100` at `Shared/MetricCard.razor:1`
|
||||
— Bootstrap-defined, mangled by the extractor's handling of the inline `@(...)` class expression.)
|
||||
|
||||
**Scoped-CSS bundle.** N/A — the project contains **zero** `*.razor.css` files, so no
|
||||
`*.bundle.scp.css` is emitted and nothing is missing from the head. (This was OtOpcUa's finding; it
|
||||
does not exist here.)
|
||||
|
||||
**Phantom CSS custom properties.** Zero. `Dashboard/Components/` contains no `var(--…)` at all —
|
||||
tokens are used only from `site.css`, and every one of them (`--ink`, `--ink-soft`, `--ink-faint`,
|
||||
`--card`, `--rule`, `--rule-strong`, `--mono`, `--accent`, `--accent-deep`, `--ok`, `--ok-bg`,
|
||||
`--bad`, `--bad-bg`, `--warn`, `--warn-bg`, `--idle`, `--idle-bg`) resolves against theme 0.3.1.
|
||||
This app has the OtOpcUa-clean result, not the router's.
|
||||
|
||||
**Button sizing — the one real foundation defect.** Theme 0.3.1 ships no `.btn` rule (confirmed:
|
||||
`.btn` appears in `layout.css` only inside a comment). But `site.css:187` does, and it sets
|
||||
`font-size` **directly** rather than through Bootstrap's variable:
|
||||
|
||||
```css
|
||||
.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }
|
||||
```
|
||||
|
||||
Bootstrap renders `.btn { font-size: var(--bs-btn-font-size) }`, and `.btn-sm` /
|
||||
`.btn-group-sm > .btn` size themselves purely by *redefining that variable*
|
||||
(`.btn-sm{--bs-btn-font-size:0.875rem}`). A literal `font-size` on `.btn` at equal specificity,
|
||||
loaded later, wins over the variable-driven declaration for **every** button — so `btn-sm` and
|
||||
`btn-group-sm` are font-size no-ops app-wide and small buttons differ from full-size ones by
|
||||
padding alone. This is the same class of defect the other two apps hit from the opposite
|
||||
direction (no `.btn` rule at all), and it takes the same fix.
|
||||
|
||||
**Dark scheme.** Theme 0.3.1 is light-only; `site.css` makes no `prefers-color-scheme` /
|
||||
`data-bs-theme` claim, and its header comment ("Layers over theme.css … every colour resolves to a
|
||||
theme.css token") is accurate. Nothing to correct.
|
||||
|
||||
### 1b. Button inventory
|
||||
|
||||
`grep -rn "btn-group"` returns **three** — this app already uses the convention where it matters:
|
||||
|
||||
| Site | Members | State |
|
||||
|---|---|---|
|
||||
| `Pages/ApiKeysPage.razor:189` | Rotate / Revoke, or Delete | Correct — `btn-group btn-group-sm`, no per-button `btn-sm`, `@if` inside the group |
|
||||
| `Pages/SessionsPage.razor:90` | Close / Kill | Correct |
|
||||
| `Pages/SessionDetailsPage.razor:34` | Close session / Kill worker | Correct |
|
||||
|
||||
Adjacent related buttons **not** yet grouped (both are feet, the playbook's named case):
|
||||
|
||||
| Site | Members | Fix |
|
||||
|---|---|---|
|
||||
| `Shared/ConfirmDialog.razor:17,22` | Cancel + `@ConfirmButtonClass` confirm, in a `modal-footer` | Wrap in `btn-group` |
|
||||
| `Pages/ApiKeysPage.razor:136,137` | Save (`type="submit"`) + Cancel, in the create-key card body | Wrap in `btn-group btn-group-sm`; fold the two `btn-sm` and drop the `me-1` spacer |
|
||||
|
||||
Refused / not candidates:
|
||||
|
||||
- `Pages/WorkersPage.razor:74` — a lone Kill button. Nothing to group.
|
||||
- `Pages/ApiKeysPage.razor:22` — lone page-head "Create API Key".
|
||||
- `Pages/BrowsePage.razor:109` (`Clear all`) and `:167` (`Remove`) — lone buttons.
|
||||
- `Shared/BrowseTreeNodeView.razor:19` `.tree-toggle` — a bare expander, deliberately unstyled as a
|
||||
button; `MainLayout.razor:32` Sign Out / `:36` Sign In are the theme's `rail-btn`, one per
|
||||
auth branch and mutually exclusive.
|
||||
|
||||
**Row actions styled as links**: none. Every action in the app is already a real `<button>`; the
|
||||
only `<a>` in `Dashboard/Components/` is `MainLayout.razor:36`, which navigates. `btn-link`: zero
|
||||
uses. `·` action separators: zero — the `·` occurrences (`BrowsePage.razor:54,102,106,287`,
|
||||
`BrowseTreeNodeView.razor:71`, `AlarmsPage.razor:220`) all separate *metadata facts* or serve as a
|
||||
bullet glyph, never actions. Inline `width:` sizing hacks: zero. `py-0`: zero.
|
||||
|
||||
**Arm→confirm flows** (restyle-only; the two-step must survive):
|
||||
|
||||
| Page | Flow |
|
||||
|---|---|
|
||||
| `SessionsPage` | Close / Kill → `ConfirmDialog` → `ConfirmPendingAsync` |
|
||||
| `SessionDetailsPage` | Close session / Kill worker → `ConfirmDialog` |
|
||||
| `WorkersPage` | Kill → `ConfirmDialog` |
|
||||
| `ApiKeysPage` | Rotate / Revoke / Delete → `ConfirmDialog`; Create → modal form |
|
||||
|
||||
**Size mismatches within a group**: none.
|
||||
|
||||
### 1c. Prose inventory (DELETE / KEEP / RELOCATE)
|
||||
|
||||
| Site | Text | Verdict |
|
||||
|---|---|---|
|
||||
| `Pages/GalaxyPage.razor:134-138` | "Browse data is served by the `galaxy_repository.v1.GalaxyRepository` gRPC service. Clients call `DiscoverHierarchy` for the full tree and `GetLastDeployTime` to detect redeployments." | **DELETE** — unconditional client-API documentation on an operator page. Every fact is already in `docs/GalaxyRepository.md` (service name at :44, `GetLastDeployTime` at :49, `DiscoverHierarchy` at :50). Plain delete, no relocation needed. |
|
||||
| `Pages/AlarmsPage.razor:151-154` | "Cleared alarms are not retained — this list reflects only alarms currently Active or ActiveAcked, refreshed every 3 seconds." | KEEP — decodes what the list contains and does not contain; the operator cannot infer "cleared alarms are absent" from the data. |
|
||||
| `Pages/AlarmsPage.razor:26-30` | Alarms-disabled banner citing `MxGateway:Alarms:Enabled` | KEEP — conditional state banner. **Config key verified against the options class**: `GatewayOptions.Alarms` → `AlarmsOptions.Enabled`, present in `appsettings.json`. |
|
||||
| `Pages/BrowsePage.razor:33-35`, `:41`, `:120-124` | Empty states | KEEP |
|
||||
| `Pages/BrowsePage.razor:70`, `:90` | "Showing the first N matches — refine the filter." / "Double-click a tag, or right-click for the menu." | KEEP — truncation notice, and the only decoder of two interactions that have no visible affordance. |
|
||||
| `Pages/SessionDetailsPage.razor:115-118` | "Waiting for events. The dashboard mirrors the session's gRPC event stream — events appear here only while a gRPC client is also consuming this session's events." | KEEP — explains an empty state that otherwise reads as a bug. |
|
||||
| `Pages/GalaxyPage.razor:33-38` | Unknown-status empty state | KEEP |
|
||||
|
||||
**Stale-claim hunt** (wrong facts outrank style): none found. No milestone labels ("F8/F9 pending",
|
||||
"Batch 2"), no dead repo hyperlinks, no superseded architecture claims. Every config key cited in
|
||||
markup resolves — `MxGateway:Alarms:Enabled` (above) is the only one. Also checked and **cleared**:
|
||||
`SettingsPage.razor:32` renders `Ldap.ServiceAccountPassword`, but
|
||||
`Configuration/GatewayConfigurationProvider.cs:30` substitutes `RedactedValue` before the snapshot
|
||||
is built, so no credential reaches the page.
|
||||
|
||||
### 1d. Density / layout scan (per page, not per file)
|
||||
|
||||
Structural mitigations already present app-wide: `site.css:137` caps `.dashboard-table td` at
|
||||
`max-width: 26rem` with `overflow-wrap: break-word`, so the OtOpcUa `/hosts` failure mode (one long
|
||||
exception widens the table until the actions column scrolls off) **cannot happen here**. Every table
|
||||
is also inside `.table-responsive`. `panel-head` appears zero times, so the "sections running
|
||||
together" tell does not fire — each group is its own `section.dashboard-section` card already.
|
||||
|
||||
Residual finding — **unbounded free-text columns**. The 26rem cap converts the horizontal blowout
|
||||
into vertical blowout: a multi-line exception makes one row several times taller than its
|
||||
neighbours and pushes the rest of the table off-screen.
|
||||
|
||||
| Site | Column | Bound to |
|
||||
|---|---|---|
|
||||
| `Shared/FaultList.razor:28` | Message | `@fault.Message` — worker/COM fault text, uncontrolled |
|
||||
| `Pages/SessionsPage.razor:86` | Fault | `session.LastFault` |
|
||||
| `Pages/WorkersPage.razor:70` | Fault | `worker.LastFault` |
|
||||
| `Shared/BrowseTreeNodeView.razor:51` | (tree row) | `@Node.LoadError` — **worst case**: `.tree-attr`/`.tree-row` siblings are `white-space: nowrap` inside a fixed-height scroller, so a long browse error stretches the left pane horizontally |
|
||||
| `Pages/DashboardHome.razor:47` | Galaxy panel | `Snapshot.Galaxy.LastError` on the compact overview |
|
||||
|
||||
Deliberately **not** truncated (the full text must stay reachable — playbook rule):
|
||||
`SessionDetailsPage.razor:84` "Last fault" and `GalaxyPage.razor:47` "Last Error" are both
|
||||
full-width rows on the drill-down page each summary links to.
|
||||
|
||||
Not firing: **identity slam** — `SessionsPage.razor:75-81` puts a worker pid next to a status chip,
|
||||
which the playbook explicitly permits ("chips stay with the name line"); no cell renders two
|
||||
identifiers. **Inline full-width expander rows** — zero `colspan` detail rows in the app.
|
||||
|
||||
### 1e. Tree tables
|
||||
|
||||
**No adoption.** `BrowsePage` + `BrowseTreeNodeView` is a lazy-loading nav/picker tree with a
|
||||
context menu and no columns — the rubric's explicit "different animal, leave it alone". No table in
|
||||
the app flattens hierarchy through a path-prefix column, hand-rolled `rowspan`, indent-by-padding,
|
||||
or faked group-header rows; `Sessions`/`Workers`/`Events`/`Alarms`/`Galaxy` are flat fact lists and
|
||||
`Top Templates` is rank-ordered (a tree would destroy load-bearing ordering). Porting the router's
|
||||
`TreeTable` here would create an orphan.
|
||||
|
||||
### Guard tests
|
||||
|
||||
Checked for source-scan tests pinning dashboard markup: none. The only `.razor` reference in the
|
||||
test project is a prose comment (`Gateway/GatewayApplicationTests.cs:116`). Nothing to re-pin.
|
||||
|
||||
## 2. Changes
|
||||
|
||||
### Foundation
|
||||
|
||||
1. **`wwwroot/css/site.css` — button sizing via Bootstrap CSS variables.** Replace the literal
|
||||
`font-size` on `.btn` with `--bs-btn-*` overrides and add the `btn-sm` / `btn-group-sm` block, so
|
||||
the small-button distinction works again. Box properties are *not* redefined wholesale —
|
||||
`border-radius: 5px` stays literal because the three existing `btn-group`s already render seamed
|
||||
under it (`.btn-group > .btn:not(:first-child)` outranks `.btn`). Carries the header comment the
|
||||
other two apps carry: delete the local copy when a `ZB.MOM.WW.Theme` release ships a `.btn` rule.
|
||||
**That release shipped the same day — see §6.**
|
||||
2. **`site.css` — define `.tree-load-status`** as a flex row matching `.tree-row`, restoring the
|
||||
indent alignment of the tree's loading/error rows.
|
||||
3. **`site.css` — define `.browse-stale-banner`** with `cursor: pointer` and tightened padding, so
|
||||
the click-to-dismiss banner reads as clickable. No behavior change.
|
||||
4. **`Dashboard/Components/DashboardDisplay.cs` — add `Abbreviate(string?, int)`.** Safe helper
|
||||
(length-checked, never a raw `[..n]` slice — the OtOpcUa #504 failure), `-` for null/whitespace,
|
||||
ellipsis on truncation.
|
||||
|
||||
No prose relocation task: the single DELETE is already docs-covered.
|
||||
|
||||
### Page batches
|
||||
|
||||
**Batch A — free-text truncation** (`Shared/FaultList.razor`, `Pages/SessionsPage.razor`,
|
||||
`Pages/WorkersPage.razor`, `Shared/BrowseTreeNodeView.razor`, `Pages/DashboardHome.razor`):
|
||||
`Abbreviate` + full text on `title`, at the five sites in 1d.
|
||||
|
||||
**Batch B — button feet** (`Shared/ConfirmDialog.razor`, `Pages/ApiKeysPage.razor`): the two
|
||||
`btn-group` wraps from 1b. `@onclick`, `disabled`, `type="submit"`, and both arm→confirm flows
|
||||
unchanged.
|
||||
|
||||
**Batch C — prose** (`Pages/GalaxyPage.razor`): delete `:134-138`.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
### Build + tests
|
||||
|
||||
- [x] `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` — 0 warnings, 0 errors
|
||||
(`TreatWarningsAsErrors=true`).
|
||||
- [x] `dotnet test src/ZB.MOM.WW.MxGateway.Tests` — 879/879 passed, the macOS baseline.
|
||||
|
||||
### Post-merge greps
|
||||
|
||||
- [x] Zero phantom `var(--…)` usages (there were none to begin with).
|
||||
- [x] Zero ghost classes remaining except the deliberate `tree-node`.
|
||||
- [x] `btn-group` count 3 → 5, matching the 1b inventory.
|
||||
- [x] Zero `·` action separators, zero inline `width:` hacks (none existed).
|
||||
- [x] Scoped-bundle link: N/A, no `*.razor.css`.
|
||||
|
||||
### Live browser gate
|
||||
|
||||
Recorded in §4 below.
|
||||
|
||||
## 4. Live browser gate — results
|
||||
|
||||
Run against a local `dotnet run` of the gateway on macOS (`http://localhost:5120`, the launch
|
||||
profile's port), not windev — the sweep is CSS/markup-only and redeploying the shared NSSM service
|
||||
was not warranted. Rig configuration (env overrides only, no committed config touched):
|
||||
`Dashboard:DisableLogin=true` (to reach the Admin-only surfaces), `Ldap:Enabled=false`,
|
||||
`Authentication:Mode=Disabled`, SQLite + Galaxy snapshot paths under the session scratchpad,
|
||||
`ApiKeyPepper` set locally. The rig was stopped and its throwaway auth DB deleted afterwards.
|
||||
|
||||
**Realistic erroring data**: the Galaxy SQL Server (`localhost`, `ZB`) does not exist on macOS, so
|
||||
every refresh fails with a genuine 250-character `Microsoft.Data.SqlClient` SSPI exception, and the
|
||||
alarm monitor's auto-opened session fails on the missing x86 worker — both real, uncontrolled
|
||||
error text of exactly the kind §1d is about.
|
||||
|
||||
| # | Check | Result | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | Computed-style probes | **PASS** | body `14.4px`; `.btn` `13.6px` (0.85rem); `.btn-sm` `12.48px` (0.78rem) — the small/full distinction is live, where before the fix both computed to `13.12px`. `btn-group-sm` members: both `12.48px`, first member's right radius `0px`, last member's `5px` → **seams intact**, confirming the literal `border-radius` does not break group machinery. `.tree-load-status` → `display: flex`; `.browse-stale-banner` → `cursor: pointer`. Stylesheet order: bootstrap → theme → layout → **site.css last**. The theme's own `.rail-toggle btn-sm` also picks up `12.48px`, so the block reaches theme-owned buttons, not just app markup. |
|
||||
| 2 | Every page leads with data, no unconditional doc block above the fold | **PASS** | All 9 routes' first blocks are `dashboard-page-header` → `metric-grid` / `dashboard-section` / state `alert`. `/galaxy` now ends at the Sync Info table — the deleted client-API paragraph is gone. |
|
||||
| 3 | Seamed `btn-group`s; destructive members red | **PASS** | `/apikeys` row actions render Rotate + Revoke as one seamed group with Revoke in `btn-outline-danger` red. The `ConfirmDialog` foot renders Cancel + a solid red Revoke seamed together. |
|
||||
| 4 | Arm→confirm: arm → confirm UI appears → **Cancel** → verify disarm | **PASS** | Revoke → "Revoke API key?" dialog → Cancel. Post-cancel probe: `dialogOpen:false`, `backdrops:0`, key status still `Active`, row action back to `btn-outline-danger` outline. No write completed. Save/Cancel in the create-key modal also verified: Cancel closes without creating; Save creates (validation message surfaced correctly on the first attempt with a missing display name). |
|
||||
| 5 | Anything the scoped bundle resurrected | **N/A** | No `*.razor.css` in the project — see 1a. |
|
||||
| 6 | KEEP-list legends / hints / empty states still render | **PASS** | `/browse`: both empty states plus the "Right-click a tag … Add to subscription panel" hint. `/alarms`: the "Cleared alarms are not retained …" legend under the table. `/galaxy`: Object Categories / Top Templates empty states. |
|
||||
| 7 | Density with realistic data; sections as separate cards; no horizontal scroll | **PASS** | `/` renders the SQL error truncated to one line with `…`; `/galaxy` renders the same error in full (the drill-down) — the intended split is visible side by side. `/apikeys` with a 3-entry `read_subtrees` constraint wraps inside the column with no table overflow. `documentElement.scrollWidth == clientWidth` on every page walked. Each group is its own card. |
|
||||
|
||||
**Not exercisable on this rig** (recorded, not claimed as passing): the `Sessions` / `Workers` /
|
||||
`Recent Faults` truncation call sites and the `BrowseTreeNodeView` load-error row need a *registered*
|
||||
session, which requires the x86 worker — on macOS the launch fails before the session is ever
|
||||
registered, so those tables stay empty. Their shared helper and the container CSS were both verified
|
||||
live by other means (the `/` Galaxy error for `Abbreviate`, the computed-style probe for
|
||||
`.tree-load-status`). Re-check them on windev the next time that service is redeployed.
|
||||
|
||||
**Side note surfaced, not acted on**: `SettingsPage` renders `LDAP service password` as
|
||||
`[redacted]` in the browser — confirming `GatewayConfigurationProvider`'s substitution end to end.
|
||||
|
||||
## 5. Follow-up gate: `/admin/secrets` delete modal (scadaproj#2)
|
||||
|
||||
Not part of the sweep. Run because the same stale "mxgw is Bootstrap-free" claim corrected in §0 was
|
||||
the stated reason this app was **excused** from the 2026-07-19 family-wide `/admin/secrets` modal
|
||||
sweep: `ConfirmDeleteModal` shipped a bare `class="modal"`, which Bootstrap 5's `.modal{display:none}`
|
||||
made permanently invisible on every Bootstrap host. The exemption's reasoning was false, so mxgw was
|
||||
in scope and its delete modal had never been live-gated. `8f7ee49` bumped `ZB.MOM.WW.Secrets.Ui` to
|
||||
`0.2.3`, which is supposed to carry the fix — but "supposed to" is what the original claim was.
|
||||
|
||||
**Static check** — decoding the UTF-16 literals out of `ZB.MOM.WW.Secrets.Ui.dll` 0.2.3 shows the
|
||||
component now inlines its own `<style>` under a private namespace: `.zb-secrets-modal` with
|
||||
`display: flex; position: fixed; inset: 0; z-index: 1081`, over a `.zb-secrets-modal-backdrop` at
|
||||
`z-index: 1080`. No bare `modal` class.
|
||||
|
||||
**Live check** — local rig, scratch secrets store (`Secrets:SqlitePath` + `ZB_SECRETS_MASTER_KEY`
|
||||
under the session scratchpad), throwaway secret `uisweep/throwaway`, delete armed but **never
|
||||
confirmed**:
|
||||
|
||||
| Probe | Result |
|
||||
|---|---|
|
||||
| Modal root classes | `zb-secrets-modal` — and `document.querySelector('.modal')` is `null`, so Bootstrap's `.modal{display:none}` has nothing to bite |
|
||||
| Modal computed style | `display: flex`, `position: fixed`, `z-index: 1081`, `visibility: visible`, `opacity: 1`, box `1600×827` |
|
||||
| Backdrop | `display: block`, `position: fixed`, `z-index: 1080`, `rgba(15,18,20,.45)`, covers the full viewport |
|
||||
| Confirm button hit-testable | `elementFromPoint` at its centre returns the button itself — not covered by the backdrop |
|
||||
| Cancel → disarm | modal and backdrop both removed; secret still listed; **no delete performed** |
|
||||
|
||||
**PASS.** The `0.2.3` fix holds on this Bootstrap host. The rig was stopped and both throwaway
|
||||
stores (secrets + auth) deleted.
|
||||
</content>
|
||||
|
||||
## 6. Follow-up: `ZB.MOM.WW.Theme` 0.3.1 → 0.4.0 (dependency bump, not UI cleanup)
|
||||
|
||||
0.4.0 upstreams the button-sizing block this sweep installed locally, written against the finding in
|
||||
§1a — the kit sizes **only** through `--bs-btn-*` variables and carries a comment in `layout.css`
|
||||
saying why, so nobody simplifies it back into literals.
|
||||
|
||||
- `ZB.MOM.WW.MxGateway.Server.csproj`: `0.3.1` → `0.4.0`. No `Directory.Packages.props` in this repo;
|
||||
the version lives on the `PackageReference`.
|
||||
- `site.css`: the four `--bs-btn-*` overrides and the whole `.btn-group-sm > .btn, .btn-sm` rule
|
||||
deleted — `layout.css` now supplies them and `<ThemeHead />` emits it before `site.css`.
|
||||
|
||||
**Trimmed, not deleted wholesale.** The local `.btn` rule also carried `border-radius: 5px`,
|
||||
`font-weight: 500`, `white-space: nowrap`, which predate the sweep and which 0.4.0 does **not**
|
||||
ship — it upstreamed sizing only. Removing the block entirely would have silently dropped three
|
||||
app-specific declarations. What remains is `.btn { border-radius: 5px; font-weight: 500;
|
||||
white-space: nowrap; }` — shape, not size. `border-radius` deliberately stays a literal rather than
|
||||
`--bs-btn-border-radius`, because `.btn-sm` redefines that variable and small buttons would shrink
|
||||
to the Bootstrap small radius.
|
||||
|
||||
**Verification**
|
||||
|
||||
- Build: 0 warnings, 0 errors. Suite: 879/879.
|
||||
- Computed-style probe re-run on 0.4.0 with the local block gone: `.btn` **13.6px**, `.btn-sm`
|
||||
**12.48px** — identical to the pre-bump numbers, so the kit rule reaches. `btn-group-sm` members
|
||||
both 12.48px with the seam intact (first member right radius `0px`, last `5px`). Retained
|
||||
declarations confirmed live on both sizes: radius `5px`, weight `500`, `nowrap`.
|
||||
- Cascade confirmed by enumerating `document.styleSheets`: `--bs-btn-font-size` is now declared in
|
||||
exactly two sheets — `bootstrap.min.css` (`1rem` / `0.875rem`) and `layout.css`
|
||||
(`.85rem` / `.78rem`). `site.css` no longer declares it, so the duplicate is gone.
|
||||
|
||||
**Tree-hygiene note.** The first suite run after the bump failed
|
||||
`GatewayTreeHygieneTests.SourceTree_ContainsNoSqliteDatabaseFiles` — unrelated to the bump. The §4
|
||||
rig's *first* start used the default **relative** `Secrets:SqlitePath` (`mxgateway-secrets.db`),
|
||||
which resolved against the server project directory and left a DB in the source tree; only the §5
|
||||
run redirected it to the scratchpad. The file was untracked, was deleted, and the suite went green.
|
||||
The §4/§5 cleanup notes were therefore incomplete as originally written — the scratchpad copies were
|
||||
removed but this one was missed. The hygiene test is what caught it, which is what it exists for.
|
||||
|
||||
## 7. Follow-up: `ZB.MOM.WW.Theme` 0.4.0 → 0.4.1 (pin-only, no rendering change here)
|
||||
|
||||
0.4.1 was published the same day to fix `.rail-btn-block`, a modifier shipped broken in 0.4.0
|
||||
(`display: block; width: auto` fills for an `<a>` but shrink-wraps a `<button>`; now
|
||||
`width: calc(100% - 1.2rem)` with `box-sizing: border-box`, accounting for `.rail-btn`'s side
|
||||
margins). Bumped for family-pin alignment.
|
||||
|
||||
**Why nothing needed re-verifying.** Confirmed rather than assumed:
|
||||
|
||||
- `diff` of the two restored packages: `theme.css` byte-identical; `layout.css` differs **only**
|
||||
inside the `.rail-btn-block` rule and its comment. The `.btn` sizing block is unchanged, so the
|
||||
§6 measurements (`.btn` 13.6px, `.btn-sm` 12.48px) still hold and the probe was not re-run.
|
||||
- This app does not use `rail-btn-block`. It does carry the exact element pair the bug turned on —
|
||||
`MainLayout.razor:32` is a form-submit `<button class="rail-btn">` (Sign Out) and
|
||||
`MainLayout.razor:36` an `<a class="rail-btn">` (Sign In) — but base `.rail-btn` is
|
||||
`display: inline-block`, which shrink-wraps both element types identically. The asymmetry only
|
||||
appears once the block modifier is applied, so this dashboard was never affected.
|
||||
|
||||
**Verification.** Build 0 warnings / 0 errors; suite **879/879**; `staticwebassets.build.json`
|
||||
resolves `zb.mom.ww.theme/0.4.1`. No stale-HTTP-cache clear was needed — restore picked 0.4.1
|
||||
directly.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Identifying A Deployed Build (Operator Runbook)
|
||||
|
||||
> **Written 2026-08-11 after a false alarm.** An investigation treated the windev production
|
||||
> binary as having no traceable provenance, on two pieces of evidence that both turned out to be
|
||||
> normal output of our own build and deploy procedure. The binary was fine. This runbook records
|
||||
> what those signals actually mean, so the next person spends minutes rather than a forensics pass.
|
||||
|
||||
## The version stamp is unreliable on Windows builds from 2026-07-09 to 2026-08-10
|
||||
|
||||
`src/Directory.Build.props` appends the git short SHA to `InformationalVersion` (`0.1.2+<sha>`) so a
|
||||
running binary can be mapped back to a commit. That stamping was introduced by `ec6f82b`
|
||||
(2026-07-09, TST-11) and was **broken on Windows for its first month**.
|
||||
|
||||
`$(MSBuildThisFileDirectory)` ends in a path separator. On Windows that trailing backslash escaped
|
||||
the closing quote of the `Exec` command, so `git rev-parse` never ran correctly; because the target
|
||||
runs with `ContinueOnError` and `ConsoleToMSBuild` (which mixes stderr into `ConsoleOutput`), git's
|
||||
failure text was stamped as the source revision. The observed form is:
|
||||
|
||||
```
|
||||
0.1.2+fatal: cannot change to ...
|
||||
```
|
||||
|
||||
`0152180` (2026-08-10 05:49, merged in `c46e5bb`) fixed it two ways: the quoted path gained a
|
||||
trailing `.` so the separator can no longer escape the quote, and `SourceRevisionId` is now gated on
|
||||
a short-SHA shape so no future git failure text can become the revision either.
|
||||
|
||||
**What this means for an operator.** A git error string in the version of a binary built on Windows
|
||||
in that window is the *expected* result of our own build. It is non-diagnostic in **both**
|
||||
directions — it neither incriminates a build nor confirms one, so it should not be treated as
|
||||
evidence of anything. macOS builds in the same window stamp correctly, as do all builds after
|
||||
`0152180`.
|
||||
|
||||
## An absent `C:\build\mxgw-deploy` is expected
|
||||
|
||||
The deploy procedure builds from a **detached worktree** (`git worktree add C:\build\mxgw-deploy
|
||||
<sha>`) so the host's own checkout, which usually sits on a feature branch, is not disturbed. The
|
||||
worktree is removed once the publish is copied out. Finding that the directory a binary was built
|
||||
from no longer exists is the normal end state of a correct deploy, not a deleted trail.
|
||||
|
||||
## What does identify a build
|
||||
|
||||
In rough order of cost:
|
||||
|
||||
1. **Behaviour over the wire.** Works against a running service, needs no host access, and is the
|
||||
fastest discriminator for the worker. `53f69cd` correlates `OnWriteComplete` onto **plain**
|
||||
`Write`/`Write2` replies; `b948e69` did so only for `WriteSecured`/`WriteSecured2`. So a plain
|
||||
`Write` whose reply carries `statuses[0]` proves the worker is at or past `53f69cd`, and an empty
|
||||
`statuses` proves it is not. Keep it non-destructive by writing to a read-only tag — the refusal
|
||||
still exercises the path and returns `OPERATIONAL_ERROR` with detail `1007`.
|
||||
2. **PDB source hashes.** Slower, needs the deployed symbols, but independent of anything the build
|
||||
stamped. This is what settled the 2026-08-11 investigation.
|
||||
3. **Deployment-side naming.** Since 2026-08-07 the server deploys to a dated directory
|
||||
(`Server-YYYYMMDD`) with the NSSM `Application`/`AppDirectory` repointed at it, and backup
|
||||
directories carry operator-chosen labels naming the work (for example
|
||||
`Worker.bak-20260809-planwrites`). Those conventions place a build in time and intent, and an
|
||||
accidental or off-book deploy tends not to follow them.
|
||||
|
||||
Note that **mixed Server and Worker SHAs are deliberate**, not drift: the two are swapped
|
||||
independently whenever the contracts are wire-identical, so a host legitimately runs one commit for
|
||||
the server and a later one for the worker.
|
||||
|
||||
## Recorded deploys
|
||||
|
||||
| Date | Host | Server | Worker |
|
||||
|---|---|---|---|
|
||||
| 2026-08-09 | windev (`10.100.0.48`) | `b948e69` (`Server-20260809`) | `53f69cd` |
|
||||
| 2026-08-09 | `wonder-app-vd03` | `b948e69` | `53f69cd` |
|
||||
|
||||
The 2026-08-09 deploy was **two separate swaps**, which is why a single build time does not describe
|
||||
it: the 2026-08-11 investigation dated the server file write to 19:20:24 and the worker to 19:50:06,
|
||||
the latter two minutes after `53f69cd` merged at 19:48. The two worker backup directories from that
|
||||
day are the second swap's fingerprint, not redundancy.
|
||||
|
||||
## The 2026-08-09 deploy returned the worker to mainline
|
||||
|
||||
Worth stating because it went unrecorded at the time and later read as a mystery rather than as the
|
||||
improvement it was. Before that deploy, production ran worker `dd7ca163` (2026-05-22), which is
|
||||
contained **only** by `origin/test/client-e2e-coverage` and is not an ancestor of `main` — meaning
|
||||
the x86 worker in production could not be rebuilt from any mainline commit. `53f69cd` is on `main`,
|
||||
which closes that. Verified 2026-08-11 with `git merge-base --is-ancestor`.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Diagnostics](../Diagnostics.md)
|
||||
- [Gateway Configuration](../GatewayConfiguration.md)
|
||||
@@ -0,0 +1,171 @@
|
||||
# SEC-36 — LDAP Service-Account Credential Rotation (Operator Runbook)
|
||||
|
||||
> **Executed 2026-08-07 — the rotation is done; this runbook is now history plus the four
|
||||
> corrections below.** A new service-account password was generated, `scadaproj/infra/glauth/config.toml`'s
|
||||
> `serviceaccount` `passsha256` was replaced and the shared GLAuth recreated, and the old value
|
||||
> (the literal this repo committed, live in the directory since 2026-06-04) no longer binds. The new
|
||||
> value now exists only in the
|
||||
> three channels this runbook names: the GLAuth `passsha256` (committed in `scadaproj`), the NSSM
|
||||
> service environment on `10.100.0.48`, and this dev Mac's user-secrets. The retired plaintext was
|
||||
> also scrubbed from `scadaproj/infra/glauth/`'s `config.toml`/`docker-compose.yml`/`README.md`
|
||||
> comments and from the host's live `docker-compose.yml` (the `*.bak-sec36` backups on the host still
|
||||
> carry it, deliberately — they are the rollback artifacts).
|
||||
>
|
||||
> **Correction 1 — host paths in step 3 were stale.** The runbook says
|
||||
> `cd ~/Desktop/scadaproj/infra/glauth` on `10.100.0.35`. That directory does not exist there:
|
||||
> `scadaproj` is a dev-workstation checkout, and the docker host runs the stack from
|
||||
> **`/home/dohertj2/zb-glauth`** (container **`zb-shared-glauth`**, project name `zb-shared-glauth`).
|
||||
> The repo remains the source of truth; deployment is the `scp` of `config.toml`/`docker-compose.yml`
|
||||
> into `~/zb-glauth` documented in `scadaproj/infra/glauth/README.md`, followed by
|
||||
> `docker compose up -d --force-recreate` there.
|
||||
>
|
||||
> **Correction 2 — `wonder-app-vd03` is out of scope, on documentary evidence.** The precondition
|
||||
> above says to check `MxGateway:Ldap:Enabled` on that host. It could not be checked directly (the
|
||||
> host is unreachable from the dev network), but it is out of scope regardless: its gateway binds a
|
||||
> **different directory** — the ScadaBridge/ScadaLink local GLAuth under `dc=scadalink`/`dc=scadabridge`,
|
||||
> not `dc=zb,dc=local` — so this credential is not one it can hold. No env var was staged there and
|
||||
> none is needed.
|
||||
>
|
||||
> **Correction 3 — step 4's dashboard verification is deferred on `10.100.0.48`; a direct bind was
|
||||
> used instead.** The NEW value **is** staged on windev (added as the 10th `AppEnvironmentExtra`
|
||||
> entry on the `MxAccessGw` NSSM service), but dashboard `/login` could not exercise it at rotation
|
||||
> time: windev's gateway was **crash-looping on a pre-existing, unrelated fault** — the deployed Server binary
|
||||
> (2026-06-25) predates the auth-DB migration of 2026-07-15, so it opens a schema-version-3 database
|
||||
> it only supports at version 2 and aborts at startup (~10k Hosting-failed events/day since at least
|
||||
> 08-06). That was a stale-deployment problem, not a rotation problem; it was filed as next-cycle
|
||||
> finding NEXT-07 and resolved by redeploy on 2026-08-07. **Verification used instead:** a direct `ldapsearch` bind as
|
||||
> `cn=serviceaccount,dc=zb,dc=local` with the new value against `10.100.0.35:3893` succeeded and
|
||||
> returned the `multi-role` entry — which is precisely the search bind the dashboard performs, minus
|
||||
> the HTTP shell. **The deferred check was completed 2026-08-07**, once windev was repaired by the
|
||||
> redeploy filed under NEXT-07. With `Dashboard:DisableLogin=false` supplied as a process-env-only
|
||||
> override on a foreground run of the new build, `GET /login` returned 200 with an antiforgery token,
|
||||
> `POST /auth/login` as `multi-role`/`password` returned 302 to `/` with a `MxGatewayDashboard`
|
||||
> cookie, the authenticated `GET /` rendered the admin nav, and an anonymous control redirected to
|
||||
> `/login?ReturnUrl=%2F` — so the rotated credential is proven through the real
|
||||
> `DashboardAuthenticator` search-bind path on the deployed host. As deployed, windev keeps
|
||||
> `DisableLogin=true`, so routine operation there does not exercise LDAP; the standing regression
|
||||
> proof is `DashboardLdapLiveTests` (5/5 green against `10.100.0.35` since commit `de67b45`).
|
||||
>
|
||||
> **Correction 4 — the lockout caution under "Verifying the rotation" is inert for this instance.**
|
||||
> It warns that GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office when testing
|
||||
> that the old value is dead. This GLAuth runs `LimitFailedBinds = false` (`config.toml:14`), so no
|
||||
> failed-bind limiter is active and the caution does not apply here. Keep the caution for any
|
||||
> instance that enables the limiter.
|
||||
|
||||
Operator steps to rotate the shared GLAuth service-account password after the repo-side
|
||||
removal landed (SEC-36). The repo change (removal of the committed value, the two supported
|
||||
secret channels, and this runbook) is already merged; the live rotation below is the
|
||||
load-bearing half and is yours to execute.
|
||||
|
||||
> **Never put the old or new password in this repo, in a commit, in a chat, or in this file.**
|
||||
> The value lives only in the GLAuth source of truth and in each host's out-of-band channel.
|
||||
|
||||
## Why
|
||||
|
||||
The dev GLAuth service-account password (`cn=serviceaccount,dc=zb,dc=local`) was historically
|
||||
committed to this repo. Removal alone is insufficient — the old value is permanently recoverable
|
||||
from git history — so **rotation is required**. Until the shared GLAuth on `10.100.0.35:3893`
|
||||
stops honoring the old value, the repo history discloses a live directory account with LDAP
|
||||
search capability over `dc=zb,dc=local`.
|
||||
|
||||
## Where the credential lives now (three channels, all bind `MxGateway:Ldap:ServiceAccountPassword`)
|
||||
|
||||
- **Source of truth:** `scadaproj/infra/glauth/config.toml` on host `10.100.0.35` (the `serviceaccount`
|
||||
user's `passsha256`). `scadaproj` is a shared monorepo — stage only the explicit glauth paths.
|
||||
- **Encrypted secrets store (gateway default):** `appsettings.json` ships `${secret:ldap/mxgateway/bind}`,
|
||||
resolved from the local encrypted store (seed with `secret set ldap/mxgateway/bind <value>`).
|
||||
- **Deployed hosts:** env var `MxGateway__Ldap__ServiceAccountPassword` in the NSSM service environment.
|
||||
- **Dev boxes:** `dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <value>`
|
||||
(the server carries `<UserSecretsId>mxaccessgw-server</UserSecretsId>`).
|
||||
|
||||
See `docs/GatewayConfiguration.md` (the `ServiceAccountPassword` row) and `glauth.md`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- SSH access to the GLAuth docker host `10.100.0.35` and to the deployed gateway host(s).
|
||||
- Write access to `scadaproj/infra/glauth/`.
|
||||
- Know which deployed hosts run LDAP-backed dashboard login:
|
||||
- **`10.100.0.48`** (`windev`) — primary; verify here.
|
||||
- **`wonder-app-vd03`** — its dashboard is disabled. **Check `MxGateway:Ldap:Enabled` there first.**
|
||||
If LDAP is disabled (`Enabled=false`), it has nothing to bind and needs no env var — skip it.
|
||||
- A generated replacement secret (see step 1). Generate the `passsha256` per `glauth.md`
|
||||
("Generate `passsha256` from a plaintext password").
|
||||
|
||||
## Cutover order
|
||||
|
||||
Follow this order so no window opens where the deployed dashboard cannot bind. **Do not rotate
|
||||
GLAuth before the deployed hosts already carry the new value.**
|
||||
|
||||
1. **Generate the new secret in `scadaproj/infra/glauth/`.** Pick a new password, compute its
|
||||
`passsha256`, and stage the change to the `serviceaccount` user in `config.toml` (do not
|
||||
`docker compose up` yet — the directory must keep honoring the OLD value until the deployed
|
||||
hosts carry the NEW one).
|
||||
|
||||
2. **Pre-stage the NEW value on every LDAP-enabled deployed host** via the env-var channel, so the
|
||||
host is ready the instant GLAuth flips:
|
||||
```powershell
|
||||
nssm get MxAccessGw AppEnvironmentExtra
|
||||
nssm set MxAccessGw AppEnvironmentExtra MxGateway__Ldap__ServiceAccountPassword=<new-value>
|
||||
# restart the service so the new environment is picked up
|
||||
nssm restart MxAccessGw
|
||||
```
|
||||
Do this on `10.100.0.48`, and on `wonder-app-vd03` **only if** `MxGateway:Ldap:Enabled=true` there.
|
||||
(Alternatively seed the encrypted store with `secret set ldap/mxgateway/bind <new-value>`; the
|
||||
env var overrides the store and is the simplest per-host mechanism.)
|
||||
At this moment the deployed host holds the NEW value but GLAuth still honors the OLD one — binds
|
||||
still fail closed against the old directory, which is expected and brief; proceed immediately.
|
||||
|
||||
3. **Rotate GLAuth on `10.100.0.35`** to honor the new value:
|
||||
```bash
|
||||
ssh 10.100.0.35
|
||||
cd ~/Desktop/scadaproj/infra/glauth
|
||||
docker compose up -d --force-recreate
|
||||
docker compose logs -f # confirm clean startup, no TOML parse error
|
||||
```
|
||||
|
||||
4. **Verify dashboard login on the deployed host(s).** Browse to the gateway dashboard on
|
||||
`10.100.0.48` and log in as `multi-role` / `password` (Administrator) — a successful login proves
|
||||
the search bind used the new service-account credential end-to-end. If `wonder-app-vd03` runs
|
||||
LDAP, verify it too; if its dashboard/LDAP is disabled, no check is needed.
|
||||
|
||||
5. **The repo change is already landed** (removal of the committed value, `<UserSecretsId>`, the
|
||||
validator message naming the two channels, and doc/scrub updates). Nothing more to commit for
|
||||
the cutover.
|
||||
|
||||
6. **Developers set user-secrets on next pull.** After pulling, a dev box with no secret configured
|
||||
will fail startup with a validation message naming the exact command. One-time per machine:
|
||||
```bash
|
||||
dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" <new-value>
|
||||
```
|
||||
(value from `scadaproj/infra/glauth/`, never from a repo file).
|
||||
|
||||
## Verifying the rotation
|
||||
|
||||
- **Primary:** dashboard `/login` as `multi-role` on `10.100.0.48` succeeds (step 4).
|
||||
- **`wonder-app-vd03`:** only if `MxGateway:Ldap:Enabled=true`; otherwise no action.
|
||||
- **Live-LDAP integration tests** (opt-in, only where the GLAuth instance is reachable):
|
||||
```bash
|
||||
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
|
||||
$env:MxGateway__Ldap__ServiceAccountPassword = "<new-value>" # shell env only, never committed
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj `
|
||||
--filter FullyQualifiedName~DashboardLdapLiveTests
|
||||
```
|
||||
A green `DashboardLdapLiveTests` run confirms the new credential binds and searches. Where GLAuth
|
||||
is unreachable, document the suite as skipped per the `docs/GatewayTesting.md` opt-in matrix.
|
||||
- **Old value is dead:** after step 3, a bind with the old password must fail. Do not test this from
|
||||
a shared-NAT box — GLAuth's 3-fail / 10-minute per-IP lockout can lock the whole office.
|
||||
|
||||
## Rollback
|
||||
|
||||
If dashboard login breaks after step 3, restore the previous `passsha256` in
|
||||
`scadaproj/infra/glauth/config.toml`, `docker compose up -d --force-recreate`, and re-point the
|
||||
deployed hosts' env var / store back to the previous value. Because the deployed hosts were
|
||||
pre-staged in step 2, the exposure window is only steps 2→4.
|
||||
|
||||
## Done criteria
|
||||
|
||||
- GLAuth on `10.100.0.35` honors only the new value.
|
||||
- Every LDAP-enabled deployed host binds with the new value (dashboard login verified).
|
||||
- The source of truth `scadaproj/infra/glauth/config.toml` carries the new `passsha256`.
|
||||
- No repo file (this one included) contains the old or new value.
|
||||
- The SEC-36 tracker rows are `Done` with this runbook cited for the operator action.
|
||||
@@ -0,0 +1,148 @@
|
||||
# TST-30 — Register A Second CI Runner (Operator Runbook)
|
||||
|
||||
> **Executed 2026-08-07 — option (a) shipped; this runbook is now history plus the one
|
||||
> correction below.** `gitea-runner-2` (runner id 5, capacity 2, labels `ubuntu-latest`/
|
||||
> `ubuntu-22.04`) runs on `10.100.0.35` from the `/opt/gitea` compose stack with the same
|
||||
> `container.network: traefik` setting as the original; its registration token is mounted from a
|
||||
> `0600` file rather than inlined in compose. The existing `gitea-runner` (id 1, capacity 4) was
|
||||
> left untouched, so capacity went 4 → 6 by addition and the change reverts by removing one
|
||||
> container. Concurrency was verified by pushing HEAD to two scratch branches while an unrelated
|
||||
> run was in flight: jobs from three runs ran simultaneously across both runners, and a
|
||||
> `gitea-runner-2` job cloned successfully from `http://gitea:3000` (the property option (c) was
|
||||
> rejected for losing).
|
||||
>
|
||||
> **Correction to the Verification and Done-criteria sections below:** they expect
|
||||
> `GET /repos/dohertj2/mxaccessgw/actions/runners` to show ≥2 runners. It does not — it still
|
||||
> returns `total_count: 0`, correctly, because both runners are registered at the **instance**
|
||||
> level, which is the very condition the "Why" section describes. Use
|
||||
> `GET /api/v1/admin/actions/runners` instead (it now lists only id 1 and id 5; id 4 was removed
|
||||
> 2026-08-07 — a local macOS `act_runner` mislabelled `ubuntu-latest`/`ubuntu-22.04`/`ubuntu-20.04`,
|
||||
> so it captured Linux-labelled jobs it had no Docker daemon to run and failed them. Its config and
|
||||
> registration are kept disabled at `~/gitea-act-runner.disabled-2026-08-07` (launchd plist at
|
||||
> `~/Library/LaunchAgents/com.dohertj2.gitea-act-runner.plist.disabled-2026-08-07`) so it can be
|
||||
> re-registered with mac-specific labels if a mac-only job ever needs one). For per-job runner
|
||||
> attribution, `GET /repos/{owner}/{repo}/actions/runs/{id}/jobs`
|
||||
> exposes `runner_id`/`runner_name` on each job; the `actions/tasks` listing does not.
|
||||
>
|
||||
> **Follow-up 2026-08-07 — token hygiene on the host.** `gitea-runner` (id 1) now takes its
|
||||
> registration token from the same `0600` file mount runner-2 uses instead of an inline plaintext
|
||||
> value in compose, and `/opt/gitea/docker-compose.yml` plus both `.bak` copies are `0600 root:root`;
|
||||
> runner-1 was recreated alone and kept its identity (`.runner` byte-identical). Both runners share
|
||||
> **one instance-scope registration token**, which was world-readable for roughly five months and is
|
||||
> still live — a probe registered runner id 6 with it, then deleted it. Gitea 1.26.4 cannot rotate
|
||||
> that token from the CLI or the API (both endpoints are get-or-create and return the same value),
|
||||
> so **the reset is a pending operator action in the admin web UI** ("Reset registration token").
|
||||
> After the reset, refresh `/opt/gitea/runner_token` with the new value and shred the two
|
||||
> token-bearing compose backups, which are the last copies of the old one.
|
||||
|
||||
Operator steps to relieve the single shared Gitea Actions runner that CI depends on. The
|
||||
repo-side half of TST-30 (documenting the shared-runner/no-cancel reality and the
|
||||
`run-windev-ci.sh` bypass) is already landed in `docs/GatewayTesting.md`; registering the
|
||||
second runner below is infrastructure work outside this repo's tree and is yours to execute.
|
||||
|
||||
## Why
|
||||
|
||||
All CI for this repo runs on one co-located `gitea-runner` container on docker host
|
||||
`10.100.0.35` with `maxParallel=1`. That runner is registered at the **instance** level, not
|
||||
scoped to this repo (`GET /repos/dohertj2/mxaccessgw/actions/runners` returns
|
||||
`total_count: 0`), so it is shared with `dohertj2/lmxopcua` and every job in every run across
|
||||
both repos executes serially on the single slot. A `mxaccessgw` push fans out to `portable`,
|
||||
`java`, `windows-x86`, and an active `lmxopcua` run blocks all of them — queue depth of
|
||||
~20–30 minutes was observed during TST-25 acceptance under cross-repo contention. Gitea 1.26
|
||||
also exposes **no run cancel or delete via the API**
|
||||
(`POST .../actions/runs/{id}/cancel` → 404, `DELETE .../actions/runs/{id}` → 400), so a
|
||||
superseded or hung run cannot be cleared and holds the slot until it finishes or times out.
|
||||
This is not a correctness problem — every job still reports accurately — but it undercuts the
|
||||
fast-feedback purpose of the TST-25 Windows tier and makes CI fragile to a single host: if
|
||||
`10.100.0.35` wedges or goes down, CI for both repos stops with no failover.
|
||||
|
||||
## Options (cheapest first)
|
||||
|
||||
- **(a) Register a second `act_runner` instance on `10.100.0.35` — recommended.** The host
|
||||
already runs `gitea-runner`; add a second `act_runner` container (or raise the existing
|
||||
runner's `maxParallel` where the docker-in-docker/resource budget allows) so at least two
|
||||
jobs run concurrently. Cheapest change, and it keeps the runner co-located on the
|
||||
`container.network: traefik` network that resolves `gitea:3000` — the property TST-03
|
||||
depended on. **Use the same `container.network: traefik` config as the existing runner.**
|
||||
- **(b) Dedicate a labelled runner to `mxaccessgw`.** Cleaner isolation — `lmxopcua` load
|
||||
never blocks this repo — but needs label wiring: register the new runner with a distinct
|
||||
label (e.g. `mxgw`) and change `.gitea/workflows/ci.yml`'s `runs-on:` for this repo's jobs
|
||||
to gate on that label (e.g. `runs-on: [ubuntu-latest, mxgw]`). Only do this if (a) proves
|
||||
insufficient — it is more moving parts for the same throughput gain, and it means `ci.yml`
|
||||
changes, which is out of scope for the doc-only half of TST-30.
|
||||
- **(c) Put the runner on windev / a second host — rejected as the primary fix.** windev is
|
||||
the Windows build target (`10.100.0.48`), not a CI host, and co-locating a Linux runner
|
||||
there loses the `gitea:3000` name resolution TST-03 relies on. Only consider if
|
||||
`10.100.0.35` genuinely runs out of capacity for a second instance.
|
||||
|
||||
Default to **(a)**. Escalate to (b) only if `lmxopcua` contention persists after a second
|
||||
instance is online (i.e., (a) is not sufficient because the two repos' combined load exceeds
|
||||
two slots).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- SSH/docker access to `10.100.0.35`.
|
||||
- The existing `gitea-runner` container's compose/run config, to copy its
|
||||
`container.network: traefik` setting and registration token flow (repo memory
|
||||
`project_gitea_ci` records this configuration).
|
||||
- Admin access to Gitea (`gitea.dohertylan.com`) to mint a new runner registration token.
|
||||
|
||||
## Steps — option (a): second runner instance
|
||||
|
||||
1. On `10.100.0.35`, locate the existing `gitea-runner` container/compose definition and copy
|
||||
its configuration for a new instance (same `container.network: traefik`, same Docker
|
||||
socket mount if it uses docker-in-docker, a distinct container name/data volume).
|
||||
2. In Gitea, generate a new runner registration token (instance-level, since the existing
|
||||
runner is registered at the instance level too — Admin → Actions → Runners, or
|
||||
`POST /admin/actions/runners/registration-token`).
|
||||
3. Register and start the second `act_runner` instance with that token, pointed at the same
|
||||
Gitea origin.
|
||||
4. Confirm both runners show online: instance runner list in the Gitea admin UI, or the
|
||||
equivalent API listing.
|
||||
|
||||
## Verification
|
||||
|
||||
- Push two branches to `mxaccessgw` back-to-back (or trigger one `mxaccessgw` push while an
|
||||
`lmxopcua` run is in flight) and confirm both runs execute **concurrently**, not serially —
|
||||
the second run's jobs should start before the first finishes, not queue behind it.
|
||||
- `GET /repos/dohertj2/mxaccessgw/actions/runners` (or the instance runner listing) shows
|
||||
**≥2** runners online.
|
||||
- Re-run the TST-25 acceptance push (a plain push to a scratch branch) and confirm queue depth
|
||||
is materially lower than the ~20–30 minute baseline observed under a concurrent `lmxopcua`
|
||||
run.
|
||||
- Confirm `windows-x86` still resolves `gitea:3000` correctly from a job scheduled on the new
|
||||
runner instance (the `traefik` network property must hold for both instances).
|
||||
|
||||
## The no-cancel reality does not go away
|
||||
|
||||
A second runner relieves contention; it does not add a cancel/delete API — Gitea 1.26 still
|
||||
returns 404/400 for both. A stale or hung run on either runner still holds its slot until it
|
||||
finishes or times out. Two runners just means one stale run blocks at most half the capacity
|
||||
instead of all of it. Do not treat the second runner as a substitute for the escape hatch: a
|
||||
specific commit can still be verified out of band without waiting on either runner via
|
||||
`CI_SHA=<sha> scripts/ci/run-windev-ci.sh <build|test|live>` (Linux, needs SSH access to
|
||||
windev) or the manual windev worktree flow — see the "Runner capacity is shared and finite"
|
||||
section in `docs/GatewayTesting.md`.
|
||||
|
||||
## Optional: workflow-level `concurrency` group
|
||||
|
||||
As belt-and-suspenders against the missing cancel API, `.gitea/workflows/ci.yml` could add a
|
||||
top-level `concurrency` group (e.g. keyed on `${{ github.ref }}`) so a newer push to the same
|
||||
branch automatically supersedes an in-flight run instead of both running to completion.
|
||||
**Verify this Gitea deployment actually honors `concurrency` and cancels the superseded run
|
||||
before relying on it** — Gitea Actions' YAML surface does not track GitHub Actions feature
|
||||
parity release-for-release, and a `concurrency` block that is silently ignored would look like
|
||||
a working safeguard while doing nothing. If verified working, this is a `ci.yml` change (not
|
||||
covered by this runbook) and should land as its own small change with its own verification
|
||||
(push twice to the same branch quickly, confirm the first run's jobs cancel).
|
||||
|
||||
## Done criteria
|
||||
|
||||
- A second `act_runner` instance (or raised `maxParallel`) is online on `10.100.0.35` with the
|
||||
same `container.network: traefik` configuration as the existing runner.
|
||||
- `GET /repos/dohertj2/mxaccessgw/actions/runners` (or the instance listing) shows ≥2 runners.
|
||||
- Two concurrent runs (one `mxaccessgw`, one `lmxopcua`, or two `mxaccessgw` pushes) execute
|
||||
in parallel rather than serially.
|
||||
- `docs/GatewayTesting.md`'s shared-runner/no-cancel prose and the `run-windev-ci.sh` bypass
|
||||
remain accurate (they describe the bypass as still valid, which it is regardless of runner
|
||||
count).
|
||||
+78
-4
@@ -173,9 +173,15 @@ the worker's current state while the corresponding live transition may still be
|
||||
buffered in the monitor's lease, so both can broadcast and the two are
|
||||
indistinguishable on the feed. This applies to the acked-state delta and equally
|
||||
to the older Raise/Clear presence repair: nothing serializes a reconcile pass
|
||||
against the in-flight live stream. Alarm-feed consumers (`StreamAlarms` clients
|
||||
and the dashboard alarm hub) must apply transitions idempotently — treat one as
|
||||
"set this alarm to this state", never as an increment or a toggle.
|
||||
against the in-flight live stream. The monitor narrows that window with a
|
||||
best-effort dedup (NEXT-03): a buffered live transition whose worker timestamp
|
||||
and resulting state the cache already carries from a repair — or whose Clear
|
||||
matches a one-reconcile-generation tombstone keyed on the instance's original
|
||||
raise timestamp — is suppressed instead of re-broadcast. The dedup fires only on
|
||||
a positive marker match (unset timestamps never suppress), so the contract stays
|
||||
at-least-once: alarm-feed consumers (`StreamAlarms` clients and the dashboard
|
||||
alarm hub) must apply transitions idempotently — treat one as "set this alarm to
|
||||
this state", never as an increment or a toggle.
|
||||
|
||||
### Alarm providers and failover
|
||||
|
||||
@@ -299,9 +305,16 @@ Default transport: one bidirectional named pipe per worker.
|
||||
Pipe name:
|
||||
|
||||
```text
|
||||
mxaccess-gateway-{gatewayProcessId}-{sessionId}
|
||||
mxgw-{gatewayProcessId}-{sessionUid}
|
||||
```
|
||||
|
||||
`sessionUid` is the session id without its `session-` prefix (the raw guid hex).
|
||||
The name is deliberately short: on Unix-like hosts (the macOS/Linux test
|
||||
matrix), .NET named pipes are Unix domain sockets at
|
||||
`$TMPDIR/CoreFxPipe_{name}`, and macOS caps the socket path at 104 bytes while
|
||||
its default per-user `TMPDIR` already spends ~49 of them. The gateway PID keeps
|
||||
the name collision-free across gateway restarts.
|
||||
|
||||
Message framing:
|
||||
|
||||
```text
|
||||
@@ -431,6 +444,24 @@ Core commands:
|
||||
- `AuthenticateUser`
|
||||
- `ArchestrAUserToId`
|
||||
|
||||
**Write completion correlation.** MXAccess writes are fire-and-forget
|
||||
at the toolkit level — the per-item outcome only exists in the later
|
||||
`OnWriteComplete` callback. For the unary write kinds — `Write`, `Write2`,
|
||||
`WriteSecured`, `WriteSecured2` — the worker
|
||||
therefore holds the unary reply for a bounded window
|
||||
(`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default 1.5 s, `0`
|
||||
disables) and, when the matching callback arrives, copies its status rows onto
|
||||
`MxCommandReply.statuses` — the reply then proves the MXAccess commit, not just
|
||||
command acceptance. `protocol_status`/`hresult` keep describing acceptance
|
||||
only; a real MXAccess write failure surfaces in `statuses[0]`, and a reply with
|
||||
empty `statuses` means unconfirmed (the callback missed the window), never
|
||||
failed. The `OnWriteComplete` event still flows on the event stream unchanged.
|
||||
Correlation is best-effort per `(server_handle, item_handle)` — the callback
|
||||
carries no transaction id, so concurrent writes to the same item within the
|
||||
window can swap rows. The bulk write commands stay
|
||||
fire-and-forget: waiting per entry would add a device round-trip of latency to
|
||||
high-rate supervisory write loops.
|
||||
|
||||
Bulk variants (single gRPC round-trip carries the full list, the worker
|
||||
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
||||
returns one result per requested entry — per-entry failures populate
|
||||
@@ -631,6 +662,49 @@ The exact field names should be adjusted to match the actual interop struct,
|
||||
but the design principle is important: do not collapse status arrays into a
|
||||
single success flag.
|
||||
|
||||
### MxStatus Detail Vocabulary
|
||||
|
||||
`MxStatusProxy.detail` carries MXAccess's `MxStatusDetail` code verbatim. The
|
||||
vocabulary below is lifted from the installed toolkit's interop enum
|
||||
(`Interop.aaMxDataConsumer` → `MxStatusDetail`, confirmed against the
|
||||
`MxNativeCodec/MxStatus.cs` map in the MXAccess analysis project — see
|
||||
`docs/DesignDecisions.md` external sources). Consumers mapping statuses onto
|
||||
another protocol (e.g. OtOpcUa's OPC UA status mapping) should key on these
|
||||
codes rather than truncating or banding the raw value:
|
||||
|
||||
| Detail | Name | Detail | Name |
|
||||
|---|---|---|---|
|
||||
| 0 | `MX_S_Success` | 1003 | `MX_E_IndexOutOfRange` |
|
||||
| 1 | `MX_E_RequestTimedOut` | 1004 | `MX_E_DataOutOfRange` |
|
||||
| 2 | `MX_E_PlatformCommunicationError` | 1005 | `MX_E_IncorrectDataType` |
|
||||
| 3 | `MX_E_InvalidPlatformId` | 1006 | `MX_E_NotReadable` |
|
||||
| 4 | `MX_E_InvalidEngineId` | 1007 | `MX_E_NotWriteable` |
|
||||
| 5 | `MX_E_EngineCommunicationError` | 1008 | `MX_E_WriteAccessDenied` |
|
||||
| 6 | `MX_E_InvalidReference` | 1009 | `MX_E_UnknownError` |
|
||||
| 7 | `MX_E_NoGalaxyRepository` | 1010 | `MX_E_ObjectInitializing` |
|
||||
| 8 | `MX_E_InvalidObjectId` | 1011 | `MX_E_EngineInitializing` |
|
||||
| 9 | `MX_E_ObjectSignatureMismatch` | 1012 | `MX_E_SecuredWrite` |
|
||||
| 10 | `MX_E_AttributeSignatureMismatch` | 1013 | `MX_E_VerifiedWrite` |
|
||||
| 11 | `MX_E_ResolvingAttribute` | 1014 | `MX_E_NoAlarmAckPrivilege` |
|
||||
| 12 | `MX_E_ResolvingObject` | 1015 | `MX_E_AlarmAckedAlready` |
|
||||
| 13 | `MX_E_WrongDataType` | 1016 | `MX_E_UserNotHavingAccessRights` |
|
||||
| 14 | `MX_E_WrongNumberOfDimensions` | 1017 | `MX_E_VerifierNotHavingVerifyRights` |
|
||||
| 15 | `MX_E_InvalidIndex` | 8000 | `MX_E_AutomationObjectSpecificError` |
|
||||
| 16 | `MX_E_IndexOutOfOrder` | 1000 | `MX_E_InvalidPrimitiveId` |
|
||||
| 17 | `MX_E_DimensionDoesNotExist` | 1001 | `MX_E_InvalidAttributeId` |
|
||||
| 18 | `MX_E_ConversionNotSupported` | 1002 | `MX_E_InvalidPropertyId` |
|
||||
| 19 | `MX_E_UnableToConvertString` | 25 | `MX_E_GalaxyRepositoryBusy` |
|
||||
| 20 | `MX_E_Overflow` | 26 | `MX_E_EngineOverloaded` |
|
||||
| 21 | `MX_E_NmxVersionMismatch` | 23 | `MX_E_LmxVersionMismatch` |
|
||||
| 22 | `MX_E_NmxInvalidCommand` | 24 | `MX_E_LmxInvalidCommand` |
|
||||
|
||||
Codes observed live in write-completion correlation: `1007`
|
||||
(`MX_E_NotWriteable` — write to a read-only attribute) and `1008`
|
||||
(`MX_E_WriteAccessDenied` — e.g. a write through a plain-advised handle that
|
||||
lacks supervisory access). `1012`/`1013` mark attributes classified for
|
||||
secured/verified writes; `1016`/`1017` are the secured-write credential
|
||||
failures.
|
||||
|
||||
For command replies, return:
|
||||
|
||||
- protocol status,
|
||||
|
||||
@@ -30,10 +30,20 @@ gw-specific role.
|
||||
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
|
||||
| Base DN | `dc=zb,dc=local` |
|
||||
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
|
||||
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` (password: `<service-account-password>`) |
|
||||
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||
|
||||
> **Service-account password is not committed (SEC-36).** The samples below show
|
||||
> `<service-account-password>` as a placeholder, not the real value. The single source of
|
||||
> truth is **`scadaproj/infra/glauth/config.toml`** on host `10.100.0.35`; the gateway consumes
|
||||
> it out-of-band (encrypted secrets store reference `${secret:ldap/mxgateway/bind}`, the
|
||||
> `MxGateway__Ldap__ServiceAccountPassword` env var on deployed hosts, or `dotnet user-secrets`
|
||||
> on dev boxes — see `docs/GatewayConfiguration.md`). The credential was historically committed
|
||||
> to this repo (and remains recoverable from git history); it **was rotated on 2026-08-07**
|
||||
> (SEC-36, executed per `docs/runbooks/SEC-36-ldap-credential-rotation.md`) and the old
|
||||
> committed value no longer binds.
|
||||
|
||||
## Pre-existing groups (LmxOpcUa role taxonomy)
|
||||
|
||||
These map cleanly onto MxAccess capability boundaries — mxaccessgw
|
||||
@@ -62,7 +72,7 @@ group below).
|
||||
| `writeconfig` | `writeconfig123` | 5006 | WriteConfigure | — | + WriteSecured (Configure) |
|
||||
| `alarmack` | `alarmack123` | 5003 | AlarmAck | — | Alarm acknowledgment |
|
||||
| `admin` | `admin123` | 5004 | ReadOnly | WriteOperate, AlarmAck, WriteTune, WriteConfigure | All roles |
|
||||
| `serviceaccount` | `serviceaccount123` | 5999 | ReadOnly | — | LDAP search capability (for bind-then-search) |
|
||||
| `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 /
|
||||
@@ -100,7 +110,7 @@ by `sAMAccountName`, not `cn`. Use this only for dev convenience.
|
||||
|
||||
```
|
||||
1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
|
||||
/ serviceaccount123).
|
||||
/ <service-account-password>).
|
||||
2. Search under dc=zb,dc=local with filter
|
||||
(uid=<entered-username>) — or any attribute the deployment
|
||||
identifies users by. GLAuth populates uid + cn.
|
||||
@@ -133,7 +143,7 @@ ldap:
|
||||
allowInsecureLdap: true # dev only
|
||||
searchBase: "dc=zb,dc=local"
|
||||
serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
serviceAccountPassword: "serviceaccount123"
|
||||
serviceAccountPassword: "<service-account-password>" # not committed; see source-of-truth note
|
||||
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
||||
displayNameAttribute: "cn"
|
||||
groupAttribute: "memberOf"
|
||||
@@ -242,7 +252,7 @@ Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
||||
|
||||
```bash
|
||||
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
||||
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
||||
-D "cn=serviceaccount,dc=zb,dc=local" -w '<service-account-password>' \
|
||||
-b "dc=zb,dc=local" "(uid=multi-role)"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Codegen freshness guard for CI (IPC-01, IPC-19, IPC-20, CLI-02).
|
||||
# Codegen freshness guard for CI (IPC-01, IPC-19, IPC-20, IPC-25, CLI-02).
|
||||
#
|
||||
# Three checks, all Linux/macOS-runnable (no Server build, no x86 worker):
|
||||
# Four checks, all Linux/macOS-runnable (no Server build, no x86 worker):
|
||||
# 1. Published client descriptor set matches the current .proto sources (delegates to
|
||||
# publish-client-proto-inputs.ps1 -Check, which normalizes source_code_info so it is
|
||||
# protoc-version tolerant).
|
||||
@@ -13,6 +13,12 @@
|
||||
# crate buildable outside the repo, CLI-02) are byte-identical to the canonical Contracts
|
||||
# protos. A drift means a .proto was edited without refreshing the vendored copies, which would
|
||||
# publish a stale wire contract to crate consumers while the in-repo build stays correct.
|
||||
# 4. The committed Go and Python client bindings match a fresh regeneration (IPC-25). The two
|
||||
# per-client generate-proto.ps1 scripts pin their generators (protoc-gen-go v1.36.11 /
|
||||
# protoc-gen-go-grpc v1.6.2 for Go; grpcio-tools 1.80.0 for Python), so a clean checkout
|
||||
# regenerates deterministic output; a non-empty git diff means a .proto was edited without
|
||||
# regenerating and committing those bindings. A missing generator FAILS the check (a skipped
|
||||
# guard is the exact silent-drift hole IPC-25 closes), never skips it.
|
||||
#
|
||||
# The x86 Worker + Worker.Tests are Windows-only and are guarded by the SSH-driven `windows-x86`
|
||||
# CI job (see docs/GatewayTesting.md, Continuous Integration), not here.
|
||||
@@ -28,7 +34,7 @@ $generatedDir = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/Generated
|
||||
$contractsProject = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Write-Host '== Check 1/2: client descriptor set freshness =='
|
||||
Write-Host '== Check 1/4: client descriptor set freshness =='
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot 'publish-client-proto-inputs.ps1') -Check
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -40,7 +46,7 @@ catch {
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Check 2/2: Contracts/Generated matches a fresh regeneration =='
|
||||
Write-Host '== Check 2/4: Contracts/Generated matches a fresh regeneration =='
|
||||
try {
|
||||
# Force a full regeneration: Grpc.Tools skips regen when the committed .cs look up to date, so
|
||||
# remove them first (the documented "del Generated/*.cs to force regen" trick).
|
||||
@@ -66,7 +72,7 @@ catch {
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Check 3/3: Rust vendored protos match canonical Contracts protos =='
|
||||
Write-Host '== Check 3/4: Rust vendored protos match canonical Contracts protos =='
|
||||
try {
|
||||
$canonicalProtoDir = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/Protos'
|
||||
$vendoredProtoDir = Join-Path $repoRoot 'clients/rust/protos'
|
||||
@@ -87,6 +93,28 @@ catch {
|
||||
$failures.Add("Rust vendored proto check failed: $($_.Exception.Message)")
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Check 4/4: Go and Python client bindings match a fresh regeneration =='
|
||||
try {
|
||||
# Regenerate both binding sets with their pinned generators, then diff. The per-client scripts
|
||||
# throw on a missing or off-pin generator, so any failure here FAILS the check rather than
|
||||
# skipping it (a skipped guard is exactly the silent-drift hole IPC-25 closes).
|
||||
$goBindingDir = 'clients/go/internal/generated'
|
||||
$pyBindingDir = 'clients/python/src/zb_mom_ww_mxgateway/generated'
|
||||
|
||||
& (Join-Path $repoRoot 'clients/go/generate-proto.ps1') | Out-Host
|
||||
& (Join-Path $repoRoot 'clients/python/generate-proto.ps1') | Out-Host
|
||||
|
||||
$bindingDiff = (& git -C $repoRoot status --porcelain -- $goBindingDir $pyBindingDir | Out-String).Trim()
|
||||
if (-not [string]::IsNullOrEmpty($bindingDiff)) {
|
||||
Write-Host $bindingDiff
|
||||
$failures.Add("Go/Python client bindings differ from a fresh regeneration. Run clients/go/generate-proto.ps1 and clients/python/generate-proto.ps1 with the pinned generators and commit $goBindingDir and $pyBindingDir.")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failures.Add("Go/Python codegen check failed (tool missing or regeneration error): $($_.Exception.Message)")
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Host 'Codegen freshness check FAILED:' -ForegroundColor Red
|
||||
|
||||
@@ -53,6 +53,16 @@ first, then merge.
|
||||
- Unreachable-host red: point at a bogus port / stop sshd, confirm the job fails fast, not hangs.
|
||||
- Concurrency: push two branches back-to-back, confirm the second remote run waits on the lock.
|
||||
- Nightly: trigger the schedule path, confirm `live` runs and a forced failure opens an issue.
|
||||
**Done 2026-08-10** (Check 6). Verified two ways: (a) production — every red nightly since
|
||||
2026-07-17 has auto-filed an issue (#126–#139) authored by the `gitea-actions` bot, e.g. run 672
|
||||
→ issue #139, with the built-in token masked to `***` in the job log; (b) a forced-failure probe
|
||||
on the throwaway branch `test/tst25-check6-nightly-issue` (run 677 → issue #140, since closed and
|
||||
the branch deleted), which reproduced the job shape with `exit 1` in place of the live step and
|
||||
confirmed the `if: failure()` step fires, the token carries issue-write, and the payload is
|
||||
well-formed. The probe also caught the one defect: `${{ github.server_url }}` is the
|
||||
runner-internal `http://gitea:3000`, so the run link in the issue body was unreachable from a
|
||||
browser — the body now uses the `PUBLIC_SERVER_URL` job env instead (the API call still targets
|
||||
`github.server_url`, which is what the job container can resolve).
|
||||
- Confirm no key material appears in job logs.
|
||||
|
||||
## Degraded mode
|
||||
|
||||
@@ -87,6 +87,12 @@ $GiteaNugetFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/inde
|
||||
$GiteaPypiFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/pypi'
|
||||
$JavaHome = '/Users/dohertj2/.local/jdks/jdk-21.0.11+10/Contents/Home'
|
||||
|
||||
# Generic Gitea package registry API (https://gitea.dohertylan.com/api/v1/packages/{owner}/{type}/{name}/{version}):
|
||||
# returns 200 when that exact name+version already exists in the given feed
|
||||
# type, 404 when it does not. Used as a pre-publish collision guard (CLI-39)
|
||||
# so a re-run of this script can never silently overwrite a published artifact.
|
||||
$GiteaPackageApiBase = 'https://gitea.dohertylan.com/api/v1/packages/dohertj2'
|
||||
|
||||
function Write-Header {
|
||||
param([string]$Text)
|
||||
Write-Host ''
|
||||
@@ -94,6 +100,64 @@ function Write-Header {
|
||||
Write-Host $Text -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Test-GiteaPackageExists {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Queries the Gitea package API for an existing name+version in a feed.
|
||||
.OUTPUTS
|
||||
$true if the package/version already exists, $false if it does not.
|
||||
Throws if the registry cannot be reached or returns anything other
|
||||
than 200/404 — callers must treat "cannot verify" as "do not publish".
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Type,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Version
|
||||
)
|
||||
|
||||
$uri = "$GiteaPackageApiBase/$Type/$Name/$Version"
|
||||
$headers = @{}
|
||||
if (-not [string]::IsNullOrEmpty($env:GITEA_TOKEN)) {
|
||||
$user = if ([string]::IsNullOrEmpty($env:GITEA_USERNAME)) { 'dohertj2' } else { $env:GITEA_USERNAME }
|
||||
$pair = "$($user):$($env:GITEA_TOKEN)"
|
||||
$basic = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
|
||||
$headers['Authorization'] = "Basic $basic"
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $uri -Headers $headers -Method Get -UseBasicParsing -ErrorAction Stop
|
||||
return ($response.StatusCode -eq 200)
|
||||
} catch {
|
||||
$statusCode = $null
|
||||
if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
|
||||
$statusCode = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
if ($statusCode -eq 404) {
|
||||
return $false
|
||||
}
|
||||
throw "Unable to query the Gitea package API for '$Type/$Name/$Version' ($uri): $($_.Exception.Message). Refusing to publish without a collision check — verify manually (or check GITEA_USERNAME/GITEA_TOKEN/network) and retry."
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-GiteaPackageNotPublished {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Aborts the script if $Name/$Version already exists in the $Type feed.
|
||||
Never force-overwrites a published artifact (CLI-39).
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Type,
|
||||
[Parameter(Mandatory)][string]$Name,
|
||||
[Parameter(Mandatory)][string]$Version
|
||||
)
|
||||
|
||||
Write-Host "Checking Gitea '$Type' feed for existing '$Name' $Version..."
|
||||
if (Test-GiteaPackageExists -Type $Type -Name $Name -Version $Version) {
|
||||
throw "Gitea package '$Name' version '$Version' already exists in the '$Type' feed. Bump the client version before publishing — this script never force-overwrites a published artifact."
|
||||
}
|
||||
Write-Host " Not found in the '$Type' feed — safe to publish '$Name' $Version." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# -------- .NET --------
|
||||
|
||||
function Invoke-PackDotnet {
|
||||
@@ -121,6 +185,15 @@ function Invoke-PackDotnet {
|
||||
if ($Publish) {
|
||||
Write-Host 'Publishing .NET packages to Gitea...' -ForegroundColor Yellow
|
||||
Get-ChildItem $OutputDir -Filter 'ZB.MOM.WW.MxGateway.*.nupkg' | ForEach-Object {
|
||||
# nupkg filenames are '<PackageId>.<Version>.nupkg'; the id itself contains
|
||||
# dots (e.g. 'ZB.MOM.WW.MxGateway.Client'), so the id capture is lazy and the
|
||||
# version capture anchors on the leading digit to split at the right dot.
|
||||
$fileBaseName = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
|
||||
if ($fileBaseName -notmatch '^(?<id>.+?)\.(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$') {
|
||||
throw "Could not parse a NuGet package id/version out of '$($_.Name)'."
|
||||
}
|
||||
Assert-GiteaPackageNotPublished -Type 'nuget' -Name $Matches.id -Version $Matches.version
|
||||
|
||||
& dotnet nuget push $_.FullName --source $GiteaNugetFeed --api-key $env:GITEA_TOKEN
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for '$($_.Name)'." }
|
||||
}
|
||||
@@ -159,6 +232,20 @@ function Invoke-PackPython {
|
||||
Write-Host "Packed Python artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
$pyprojectPath = Join-Path $RepoRoot 'clients/python/pyproject.toml'
|
||||
$pyprojectContent = Get-Content $pyprojectPath -Raw
|
||||
# Scope to the [project] section (not just the first "version = ..." line
|
||||
# in the file) — [build-system]/[tool.*] sections can carry their own
|
||||
# version-shaped keys, and matching the file's first hit would be luck
|
||||
# of ordering, not correctness.
|
||||
if ($pyprojectContent -notmatch '(?ms)^\[project\](?<section>.*?)(?=^\[|\z)') {
|
||||
throw "Could not find a [project] section in '$pyprojectPath'."
|
||||
}
|
||||
if ($Matches.section -notmatch '(?m)^\s*version\s*=\s*"([^"]+)"') {
|
||||
throw "Could not find [project].version in '$pyprojectPath'."
|
||||
}
|
||||
Assert-GiteaPackageNotPublished -Type 'pypi' -Name 'zb-mom-ww-mxaccess-gateway-client' -Version $Matches[1]
|
||||
|
||||
Write-Host 'Publishing Python distribution to Gitea...' -ForegroundColor Yellow
|
||||
$wheels = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.whl')
|
||||
$sdists = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.tar.gz')
|
||||
@@ -206,6 +293,20 @@ function Invoke-PackRust {
|
||||
Write-Host "Packed Rust artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
$cargoTomlPath = Join-Path $rustDir 'Cargo.toml'
|
||||
$cargoTomlContent = Get-Content $cargoTomlPath -Raw
|
||||
# Scope to the [package] section specifically — Cargo.toml also carries a
|
||||
# [workspace.package] section with its own "version = ..." line (today
|
||||
# identical, by convention, not by anything this regex can rely on), and
|
||||
# matching whichever comes first in the file is luck of ordering.
|
||||
if ($cargoTomlContent -notmatch '(?ms)^\[package\](?<section>.*?)(?=^\[|\z)') {
|
||||
throw "Could not find a [package] section in '$cargoTomlPath'."
|
||||
}
|
||||
if ($Matches.section -notmatch '(?m)^\s*version\s*=\s*"([^"]+)"') {
|
||||
throw "Could not find [package] version in '$cargoTomlPath'."
|
||||
}
|
||||
Assert-GiteaPackageNotPublished -Type 'cargo' -Name 'zb-mom-ww-mxgateway-client' -Version $Matches[1]
|
||||
|
||||
Write-Host 'Publishing Rust crate to Gitea...' -ForegroundColor Yellow
|
||||
Push-Location (Join-Path $RepoRoot 'clients/rust')
|
||||
try {
|
||||
@@ -269,6 +370,21 @@ function Invoke-PackJava {
|
||||
Write-Host "Packed Java artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
$buildGradlePath = Join-Path $javaDir 'build.gradle'
|
||||
$buildGradleContent = Get-Content $buildGradlePath -Raw
|
||||
if ($buildGradleContent -notmatch "(?m)^\s*group\s*=\s*'([^']+)'") {
|
||||
throw "Could not find subprojects { group = '...' } in '$buildGradlePath'."
|
||||
}
|
||||
$javaGroup = $Matches[1]
|
||||
if ($buildGradleContent -notmatch "(?m)^\s*version\s*=\s*'([^']+)'") {
|
||||
throw "Could not find subprojects { version = '...' } in '$buildGradlePath'."
|
||||
}
|
||||
$javaVersion = $Matches[1]
|
||||
# Gitea's Maven package API identifies the package as "groupId:artifactId",
|
||||
# not the bare artifact id — passing just the artifact id here would query
|
||||
# a name that never exists and silently defeat the guard.
|
||||
Assert-GiteaPackageNotPublished -Type 'maven' -Name "$javaGroup`:zb-mom-ww-mxgateway-client" -Version $javaVersion
|
||||
|
||||
Write-Host 'Publishing Java artifacts to Gitea Maven feed...' -ForegroundColor Yellow
|
||||
Push-Location $javaDir
|
||||
try {
|
||||
|
||||
@@ -36,6 +36,23 @@ if ($Version -notmatch '^v\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$') {
|
||||
throw "Version '$Version' must match semver vX.Y.Z (optionally with -prerelease suffix)."
|
||||
}
|
||||
|
||||
# CLI-21 guard: the tag must match what the module itself reports via
|
||||
# ClientVersion, or `go get <module>@vX.Y.Z` resolves a tag whose module
|
||||
# code disagrees with its own version constant.
|
||||
$versionGoPath = Join-Path $PSScriptRoot '..' 'clients/go/mxgateway/version.go'
|
||||
if (-not (Test-Path $versionGoPath)) {
|
||||
throw "Could not find '$versionGoPath' to verify ClientVersion before tagging."
|
||||
}
|
||||
$versionGoContent = Get-Content $versionGoPath -Raw
|
||||
if ($versionGoContent -notmatch 'ClientVersion\s*=\s*"([^"]+)"') {
|
||||
throw "Could not find a ClientVersion = `"...`" constant in '$versionGoPath'."
|
||||
}
|
||||
$clientVersion = $Matches[1]
|
||||
$tagVersion = $Version.TrimStart('v')
|
||||
if ($clientVersion -ne $tagVersion) {
|
||||
throw "clients/go/mxgateway/version.go ClientVersion is '$clientVersion' but the requested tag is '$tagVersion'. Update ClientVersion to match before tagging."
|
||||
}
|
||||
|
||||
$tag = "clients/go/$Version"
|
||||
Write-Host "Creating Go-module tag: $tag" -ForegroundColor Cyan
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
|
||||
<!-- TST-11: single-source the .NET-side version for Server, Worker, Contracts, and tests
|
||||
(they otherwise stamp the SDK default 1.0.0, so a deployed gateway cannot be correlated
|
||||
to a release). Kept at 0.1.2 to match the Contracts package and the aligned Python/Rust/
|
||||
Go clients; the Java client leads at 0.2.0 after its JDK-17 retarget. The git short SHA is
|
||||
appended to InformationalVersion (0.1.2+<sha>) so support can map a running binary to a
|
||||
commit; the query is guarded so a build outside a git checkout still succeeds. -->
|
||||
to a release). Server/Worker/Tests stay at this default. CLI-39 (2026-08-07) moved the
|
||||
published `ZB.MOM.WW.MxGateway.Contracts` and `.Client` nuget packages to 0.2.0 via an
|
||||
explicit <Version> override in Contracts.csproj (MSBuild property-last-write-wins over
|
||||
this Directory.Build.props default) — Server/Worker assembly stamping and the published
|
||||
client packages are deliberately decoupled; a broader 0.2.0 alignment for Server/Worker
|
||||
is a separate, not-yet-made decision. The git short SHA is appended to
|
||||
InformationalVersion (0.1.2+<sha>) so support can map a running binary to a commit; the
|
||||
query is guarded so a build outside a git checkout still succeeds. -->
|
||||
<PropertyGroup>
|
||||
<Version>0.1.2</Version>
|
||||
</PropertyGroup>
|
||||
@@ -22,7 +26,10 @@
|
||||
<Target Name="StampSourceRevision"
|
||||
BeforeTargets="GetAssemblyVersion;GenerateAssemblyInfo"
|
||||
Condition="'$(SourceRevisionId)' == ''">
|
||||
<Exec Command="git -C "$(MSBuildThisFileDirectory)" rev-parse --short HEAD"
|
||||
<!-- The trailing "." is load-bearing: $(MSBuildThisFileDirectory) ends in a path
|
||||
separator, and on Windows that trailing backslash escapes the closing quote,
|
||||
mangling the command so git's stderr got stamped as the revision (NEXT-09). -->
|
||||
<Exec Command="git -C "$(MSBuildThisFileDirectory)." rev-parse --short HEAD"
|
||||
ConsoleToMSBuild="true"
|
||||
StandardOutputImportance="Low"
|
||||
ContinueOnError="true"
|
||||
@@ -30,7 +37,9 @@
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="_StampedGitSha" />
|
||||
</Exec>
|
||||
<PropertyGroup>
|
||||
<SourceRevisionId Condition="'$(_StampedGitSha)' != ''">$(_StampedGitSha.Trim())</SourceRevisionId>
|
||||
<!-- Accept only something that looks like a git short SHA; Exec's ConsoleOutput
|
||||
mixes in stderr, so any git failure text must never become the revision. -->
|
||||
<SourceRevisionId Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_StampedGitSha.Trim())', '^[0-9a-f]{7,40}$'))">$(_StampedGitSha.Trim())</SourceRevisionId>
|
||||
</PropertyGroup>
|
||||
</Target>
|
||||
|
||||
|
||||
@@ -8012,6 +8012,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
/// outcome when it arrives within the worker's bounded wait — see
|
||||
/// MxCommandReply.statuses.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||
public sealed partial class WriteCommand : pb::IMessage<WriteCommand>
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
@@ -8330,6 +8335,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same statuses correlation as WriteCommand.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||
public sealed partial class Write2Command : pb::IMessage<Write2Command>
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
@@ -8694,6 +8702,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
/// outcome when it arrives within the worker's bounded wait — see
|
||||
/// MxCommandReply.statuses.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||
public sealed partial class WriteSecuredCommand : pb::IMessage<WriteSecuredCommand>
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
@@ -9053,6 +9066,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
/// outcome when it arrives within the worker's bounded wait — see
|
||||
/// MxCommandReply.statuses.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
|
||||
public sealed partial class WriteSecured2Command : pb::IMessage<WriteSecured2Command>
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
@@ -17204,6 +17222,21 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
private static readonly pb::FieldCodec<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> _repeated_statuses_codec
|
||||
= pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser);
|
||||
private readonly pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> statuses_ = new pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy>();
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// window can swap rows. The OnWriteComplete event still flows on the event
|
||||
/// stream unchanged. Bulk write kinds and all non-write kinds leave this
|
||||
/// field as before.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy> Statuses {
|
||||
@@ -22796,6 +22829,12 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
private static readonly pb::FieldCodec<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent> _repeated_events_codec
|
||||
= pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser);
|
||||
private readonly pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent> events_ = new pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent>();
|
||||
/// <summary>
|
||||
/// The reply is bounded by both a server-side count cap and the negotiated
|
||||
/// worker-frame byte cap; a reply may therefore carry fewer events than
|
||||
/// `max_events` and fewer than are queued. Callers drain iteratively until an
|
||||
/// empty reply.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent> Events {
|
||||
@@ -24510,6 +24549,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
/// after_worker_sequence = oldest_available_sequence - 1 in the next
|
||||
/// StreamEventsRequest, which will cause the server to replay starting at
|
||||
/// oldest_available_sequence (the first retained event).
|
||||
/// When nothing is retained (the replay ring is empty), this is the next sequence
|
||||
/// that can be delivered — `highest observed + 1` — and the `oldest - 1` resume
|
||||
/// formula remains valid: it resolves to the highest sequence already seen, so the
|
||||
/// follow-up resume replays nothing, reports no gap, and every newer live event
|
||||
/// passes. The interval evicted is unchanged.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
|
||||
@@ -1164,6 +1164,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
/// instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
/// "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
/// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
/// Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
/// including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
/// to fit rather than emit an oversized frame.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
|
||||
@@ -241,6 +241,9 @@ message ActivateCommand {
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
message WriteCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -248,6 +251,7 @@ message WriteCommand {
|
||||
int32 user_id = 4;
|
||||
}
|
||||
|
||||
// Same statuses correlation as WriteCommand.
|
||||
message Write2Command {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -256,6 +260,9 @@ message Write2Command {
|
||||
int32 user_id = 5;
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
message WriteSecuredCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -266,6 +273,9 @@ message WriteSecuredCommand {
|
||||
MxValue value = 5;
|
||||
}
|
||||
|
||||
// The unary reply's statuses field carries the correlated OnWriteComplete
|
||||
// outcome when it arrives within the worker's bounded wait — see
|
||||
// MxCommandReply.statuses.
|
||||
message WriteSecured2Command {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
@@ -525,6 +535,19 @@ message MxCommandReply {
|
||||
// transport failures.
|
||||
optional int32 hresult = 5;
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// window can swap rows. The OnWriteComplete event still flows on the event
|
||||
// stream unchanged. Bulk write kinds and all non-write kinds leave this
|
||||
// field as before.
|
||||
repeated MxStatusProxy statuses = 7;
|
||||
string diagnostic_message = 8;
|
||||
|
||||
@@ -676,6 +699,10 @@ message WorkerInfoReply {
|
||||
}
|
||||
|
||||
message DrainEventsReply {
|
||||
// The reply is bounded by both a server-side count cap and the negotiated
|
||||
// worker-frame byte cap; a reply may therefore carry fewer events than
|
||||
// `max_events` and fewer than are queued. Callers drain iteratively until an
|
||||
// empty reply.
|
||||
repeated MxEvent events = 1;
|
||||
}
|
||||
|
||||
@@ -760,6 +787,11 @@ message ReplayGap {
|
||||
// after_worker_sequence = oldest_available_sequence - 1 in the next
|
||||
// StreamEventsRequest, which will cause the server to replay starting at
|
||||
// oldest_available_sequence (the first retained event).
|
||||
// When nothing is retained (the replay ring is empty), this is the next sequence
|
||||
// that can be delivered — `highest observed + 1` — and the `oldest - 1` resume
|
||||
// formula remains valid: it resolves to the highest sequence already seen, so the
|
||||
// follow-up resume replays nothing, reports no gap, and every newer live event
|
||||
// passes. The interval evicted is unchanged.
|
||||
uint64 oldest_available_sequence = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ message GatewayHello {
|
||||
// instead of a hard-coded default; 0 (an older gateway that never set the field) means
|
||||
// "use the worker's built-in default". Sits above the public gRPC cap by an
|
||||
// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
|
||||
// Every worker->gateway frame — events, heartbeats, faults, and control replies
|
||||
// including DrainEvents — must serialize within this limit; reply builders truncate
|
||||
// to fit rather than emit an oversized frame.
|
||||
uint32 max_frame_bytes = 4;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Contracts</PackageId>
|
||||
<Version>0.1.2</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Authors>Joseph Doherty</Authors>
|
||||
<Company>ZB MOM WW</Company>
|
||||
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||
|
||||
@@ -14,7 +14,19 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class DashboardLdapLiveTests
|
||||
{
|
||||
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
||||
/// <summary>
|
||||
/// The shared dev/test directory issues every human tester the same well-known password, so
|
||||
/// the fixtures name it once rather than repeating a literal that drifts per test. This is a
|
||||
/// published dev credential (see <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
|
||||
/// not a secret — unlike the service-account bind password, which is never in source and must
|
||||
/// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
|
||||
/// </summary>
|
||||
private const string SharedDirectoryPassword = "password";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
|
||||
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
||||
@@ -23,7 +35,7 @@ public sealed class DashboardLdapLiveTests
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
SharedDirectoryPassword,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
@@ -38,21 +50,43 @@ public sealed class DashboardLdapLiveTests
|
||||
&& claim.Value == DashboardRoles.Admin);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
||||
/// <summary>
|
||||
/// Verifies that <c>gw-viewer</c> — a shared-directory user whose only group is GwReader
|
||||
/// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied
|
||||
/// even though its bind succeeds, and that the denial is indistinguishable from the
|
||||
/// unknown-user denial.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
||||
public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
|
||||
{
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"readonly",
|
||||
"readonly123",
|
||||
"gw-viewer",
|
||||
SharedDirectoryPassword,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
|
||||
|
||||
// This test used to assert the failure message did not echo the credential literal.
|
||||
// That check cannot survive the move to the shared directory: the real password is the
|
||||
// word "password", which legitimately occurs in the generic denial text ("The username
|
||||
// or password is invalid, ..."), so the assertion would fail for the wrong reason. The
|
||||
// no-leak property is still covered — with a distinctive literal — by
|
||||
// AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is
|
||||
// asserted here instead is the property this fixture is actually uniquely able to prove:
|
||||
// an authorization failure (valid credentials, no mapped role) must be reported with the
|
||||
// same message as an authentication failure, so the response cannot be used to enumerate
|
||||
// valid accounts.
|
||||
DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync(
|
||||
"no-such-user-9f3c1",
|
||||
"irrelevant-password",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage));
|
||||
Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
||||
@@ -98,9 +132,11 @@ public sealed class DashboardLdapLiveTests
|
||||
[LiveLdapFact]
|
||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||
{
|
||||
// Exercises the connect-failure path: a closed loopback port produces a
|
||||
// connection error that the shared LdapAuthService must absorb into a Fail
|
||||
// result rather than propagating an exception to the dashboard.
|
||||
// Exercises the connect-failure path: overriding only the port keeps whatever host
|
||||
// the run targets (localhost by default, the shared GLAuth under the
|
||||
// MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so
|
||||
// the connection error the shared LdapAuthService must absorb into a Fail result —
|
||||
// rather than propagate as an exception to the dashboard — is reproduced either way.
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||
{
|
||||
// 1 is a reserved port number that no LDAP server listens on.
|
||||
@@ -109,7 +145,7 @@ public sealed class DashboardLdapLiveTests
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"admin",
|
||||
"admin123",
|
||||
SharedDirectoryPassword,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
@@ -147,8 +183,10 @@ public sealed class DashboardLdapLiveTests
|
||||
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
|
||||
/// unreachable-server test's timing) at whatever value the operator configured, and
|
||||
/// cannot silently drop a field added to the shared type. The gateway's
|
||||
/// <c>appsettings.json</c> seeds the dev directory connection (localhost:3893,
|
||||
/// plaintext, AllowInsecure).
|
||||
/// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
|
||||
/// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
|
||||
/// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
|
||||
/// <c>AddEnvironmentVariables()</c> layer below applies.
|
||||
/// </summary>
|
||||
private static LibraryLdapOptions LibraryOptions()
|
||||
{
|
||||
|
||||
@@ -34,6 +34,14 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
||||
private readonly List<Subscriber> _subscribers = [];
|
||||
|
||||
// NEXT-03 dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
|
||||
// the most recent reconcile pass, keyed by reference with the instance's original raise
|
||||
// timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate
|
||||
// of the repair and is suppressed. One generation deep: each reconcile pass replaces the map,
|
||||
// so a tombstone lives at least one reconcile interval — far longer than the lease buffer the
|
||||
// duplicate would be sitting in — and the map stays bounded by the feed's churn per interval.
|
||||
private readonly Dictionary<string, Timestamp> _clearedByReconcile = new(StringComparer.Ordinal);
|
||||
|
||||
// Current provider status (mode + degraded + reason + since), guarded by _sync.
|
||||
// Initialized to the alarm-manager, not-degraded baseline so a late joiner sees
|
||||
// a sensible status even before any OnAlarmProviderModeChanged event arrives.
|
||||
@@ -413,17 +421,60 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
{
|
||||
if (transition.TransitionKind == AlarmTransitionKind.Clear)
|
||||
{
|
||||
_alarms.Remove(reference);
|
||||
bool wasKnown = _alarms.Remove(reference);
|
||||
if (!wasKnown && IsDuplicateOfReconcileClear(reference, transition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_alarms[reference] = SnapshotFromTransition(transition);
|
||||
ActiveAlarmSnapshot snapshot = SnapshotFromTransition(transition);
|
||||
bool duplicate = _alarms.TryGetValue(reference, out ActiveAlarmSnapshot? existing)
|
||||
&& IsDuplicateOfCachedState(existing, snapshot);
|
||||
_alarms[reference] = snapshot;
|
||||
if (duplicate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Broadcast(new AlarmFeedMessage { Transition = transition }, reference);
|
||||
}
|
||||
}
|
||||
|
||||
// NEXT-03: best-effort dedup of the reconcile/live race. A reconcile that already synthesized
|
||||
// this transition as a feed repair left the cache carrying the worker's transition timestamp
|
||||
// and resulting state — both derived from the same worker-side value the live transition
|
||||
// carries — so an exact (timestamp, state) match means this live transition's outcome has
|
||||
// already been broadcast. Suppress only on a positive match: an unset timestamp on either
|
||||
// side keeps today's at-least-once behavior.
|
||||
private static bool IsDuplicateOfCachedState(ActiveAlarmSnapshot existing, ActiveAlarmSnapshot incoming)
|
||||
{
|
||||
return existing.LastTransitionTimestamp is not null
|
||||
&& incoming.LastTransitionTimestamp is not null
|
||||
&& existing.LastTransitionTimestamp.Equals(incoming.LastTransitionTimestamp)
|
||||
&& existing.CurrentState == incoming.CurrentState;
|
||||
}
|
||||
|
||||
// NEXT-03, the Clear leg. A reconcile Clear repair removes the cache entry before the buffered
|
||||
// live Clear drains, so there is no cached state to compare against; the tombstone recorded by
|
||||
// ApplyReconcile identifies the cleared instance by its original raise timestamp instead. The
|
||||
// match consumes the tombstone, so a genuinely new raise/clear cycle (which carries a newer
|
||||
// original raise timestamp) is never swallowed. Caller holds _sync.
|
||||
private bool IsDuplicateOfReconcileClear(string reference, OnAlarmTransitionEvent transition)
|
||||
{
|
||||
if (transition.OriginalRaiseTimestamp is not null
|
||||
&& _clearedByReconcile.TryGetValue(reference, out Timestamp? clearedInstance)
|
||||
&& clearedInstance.Equals(transition.OriginalRaiseTimestamp))
|
||||
{
|
||||
_clearedByReconcile.Remove(reference);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handles the worker's provider-mode-change event: updates the stored provider
|
||||
// status, broadcasts it to every subscriber (provider status is global, not
|
||||
// alarm-scoped), records the switch metric, and forces a cache reconcile so the
|
||||
@@ -533,11 +584,14 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
//
|
||||
// Delivery semantics: feed repair transitions are AT-LEAST-ONCE, not exactly-once. A reconcile
|
||||
// reads the worker's current state while the corresponding live transition may still be
|
||||
// buffered in the alarm lease's channel; both then broadcast, and the two are indistinguishable
|
||||
// on the feed. This is inherent to the reconcile design and pre-dates the acked-state delta
|
||||
// (the Raise/Clear repair has always had it), since nothing serializes a reconcile against the
|
||||
// in-flight live stream. Consumers must therefore treat alarm state idempotently — apply a
|
||||
// transition as "set the alarm to this state", never as an increment or a toggle.
|
||||
// buffered in the alarm lease's channel; both would then broadcast, and the two are
|
||||
// indistinguishable on the feed, since nothing serializes a reconcile against the in-flight
|
||||
// live stream. ApplyTransition narrows that window with a best-effort dedup (NEXT-03): a live
|
||||
// transition whose worker timestamp and resulting state the cache already carries — or whose
|
||||
// Clear matches a tombstone recorded below — was already broadcast as a repair and is
|
||||
// suppressed. The dedup fires only on a positive marker match, so the contract stays
|
||||
// at-least-once: consumers must still treat alarm state idempotently — apply a transition as
|
||||
// "set the alarm to this state", never as an increment or a toggle.
|
||||
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
||||
{
|
||||
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
|
||||
@@ -551,10 +605,19 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
// Previous-generation tombstones have outlived the buffered live transitions they
|
||||
// guard against (one full reconcile interval); start this pass's generation fresh.
|
||||
_clearedByReconcile.Clear();
|
||||
|
||||
foreach (KeyValuePair<string, ActiveAlarmSnapshot> existing in _alarms)
|
||||
{
|
||||
if (!next.ContainsKey(existing.Key))
|
||||
{
|
||||
if (existing.Value.OriginalRaiseTimestamp is not null)
|
||||
{
|
||||
_clearedByReconcile[existing.Key] = existing.Value.OriginalRaiseTimestamp;
|
||||
}
|
||||
|
||||
Broadcast(
|
||||
new AlarmFeedMessage { Transition = TransitionFromSnapshot(existing.Value, AlarmTransitionKind.Clear) },
|
||||
existing.Key);
|
||||
|
||||
@@ -145,7 +145,12 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountPassword,
|
||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled. "
|
||||
+ "Never commit it: on dev boxes set user-secrets "
|
||||
+ "(dotnet user-secrets set \"MxGateway:Ldap:ServiceAccountPassword\" <value>); "
|
||||
+ "on deployed hosts set the environment variable "
|
||||
+ "MxGateway__Ldap__ServiceAccountPassword. "
|
||||
+ "(appsettings.json ships the ${secret:ldap/mxgateway/bind} store reference as the default.)",
|
||||
builder);
|
||||
AddIfBlank(
|
||||
options.UserNameAttribute,
|
||||
@@ -220,6 +225,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||
builder);
|
||||
if (options.WriteCompletionWaitMilliseconds < 0)
|
||||
{
|
||||
builder.Add("MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.");
|
||||
}
|
||||
AddIfNotPositive(
|
||||
options.ShutdownTimeoutSeconds,
|
||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||
|
||||
@@ -24,6 +24,15 @@ public sealed class WorkerOptions
|
||||
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
|
||||
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded wait, in milliseconds, the worker holds a WriteSecured/WriteSecured2
|
||||
/// reply for the matching MXAccess OnWriteComplete callback so the reply's
|
||||
/// statuses carry the real commit outcome. 0 disables the wait. Deployments
|
||||
/// raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A
|
||||
/// write resilience timeout) must raise those in step.
|
||||
/// </summary>
|
||||
public int WriteCompletionWaitMilliseconds { get; init; } = 1500;
|
||||
|
||||
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
|
||||
public int ShutdownTimeoutSeconds { get; init; } = 10;
|
||||
|
||||
|
||||
@@ -36,6 +36,32 @@ public static class DashboardDisplay
|
||||
return string.IsNullOrWhiteSpace(value) ? "-" : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a nullable text value for display, shortened to a maximum length.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For table cells bound to text the gateway does not control — fault messages, COM
|
||||
/// exception text, SQL errors. One multi-line exception otherwise makes a single row
|
||||
/// several times taller than its neighbours. Call sites keep the full text reachable
|
||||
/// on the element's <c>title</c> and on the row's detail page.
|
||||
/// </remarks>
|
||||
/// <param name="value">The text to format.</param>
|
||||
/// <param name="maxLength">Maximum characters to render before the ellipsis.</param>
|
||||
/// <returns>Formatted text, ellipsized when longer than <paramref name="maxLength"/>, or "-" if null or empty.</returns>
|
||||
public static string Abbreviate(string? value, int maxLength = 80)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
// Length-checked, never a bare range slice: a value shorter than maxLength
|
||||
// would throw and take the whole page render down with it.
|
||||
return value.Length <= maxLength
|
||||
? value
|
||||
: string.Concat(value.AsSpan(0, maxLength).TrimEnd(), "…");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a long count value for display with thousands separator.
|
||||
/// </summary>
|
||||
|
||||
@@ -133,8 +133,10 @@ else
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-success btn-sm me-1" disabled="@IsBusy">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Create API key actions">
|
||||
<button type="submit" class="btn btn-success" disabled="@IsBusy">Save</button>
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,8 @@ else
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
||||
{
|
||||
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
|
||||
@* Overview stays compact; the Galaxy page renders the error in full. *@
|
||||
<div class="empty-state mt-2" title="@Snapshot.Galaxy.LastError">@DashboardDisplay.Abbreviate(Snapshot.Galaxy.LastError, 160)</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -131,11 +131,6 @@ else
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-secondary small mt-2">
|
||||
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
|
||||
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
|
||||
<code>GetLastDeployTime</code> to detect redeployments.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ else
|
||||
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(session.LastFault)</td>
|
||||
@* Full text stays reachable on the tooltip and on the session detail page. *@
|
||||
<td title="@session.LastFault">@DashboardDisplay.Abbreviate(session.LastFault)</td>
|
||||
@if (CanManage)
|
||||
{
|
||||
<td>
|
||||
|
||||
@@ -67,7 +67,8 @@ else
|
||||
<td><StatusBadge Text="@worker.State.ToString()" /></td>
|
||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
|
||||
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(worker.LastFault)</td>
|
||||
@* Full text stays reachable on the tooltip and on the session detail page. *@
|
||||
<td title="@worker.LastFault">@DashboardDisplay.Abbreviate(worker.LastFault)</td>
|
||||
@if (CanManage)
|
||||
{
|
||||
<td>
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
}
|
||||
else if (Node.LoadState == BrowseLoadState.Error)
|
||||
{
|
||||
<div class="tree-load-status text-danger">
|
||||
@* Abbreviated: sibling tree rows are nowrap inside a fixed-height
|
||||
scroller, so a full COM/SQL error would stretch the whole pane. *@
|
||||
<div class="tree-load-status text-danger" title="@Node.LoadError">
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
<span>Failed to load: @Node.LoadError</span>
|
||||
<span>Failed to load: @DashboardDisplay.Abbreviate(Node.LoadError, 60)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,18 @@
|
||||
<p class="mb-0">@Message</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="OnCancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn @ConfirmButtonClass"
|
||||
disabled="@IsBusy"
|
||||
@onclick="OnConfirm">
|
||||
@ConfirmLabel
|
||||
</button>
|
||||
<div class="btn-group" role="group" aria-label="Confirm or cancel">
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="OnCancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn @ConfirmButtonClass"
|
||||
disabled="@IsBusy"
|
||||
@onclick="OnConfirm">
|
||||
@ConfirmLabel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ else
|
||||
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
|
||||
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
||||
<td><StatusBadge Text="@fault.State" /></td>
|
||||
<td>@fault.Message</td>
|
||||
<td title="@fault.Message">@DashboardDisplay.Abbreviate(fault.Message)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
||||
@@ -417,7 +417,8 @@ public sealed class SessionManager : ISessionManager
|
||||
string? clientIdentity,
|
||||
string? ownerKeyId)
|
||||
{
|
||||
string sessionId = CreateSessionId();
|
||||
string sessionUid = Guid.NewGuid().ToString("N");
|
||||
string sessionId = $"session-{sessionUid}";
|
||||
string backendName = string.IsNullOrWhiteSpace(request.RequestedBackend)
|
||||
? GatewayContractInfo.DefaultBackendName
|
||||
: request.RequestedBackend!;
|
||||
@@ -425,7 +426,11 @@ public sealed class SessionManager : ISessionManager
|
||||
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
|
||||
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
|
||||
TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds);
|
||||
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}";
|
||||
// The short prefix and bare guid keep the pipe's Unix-domain-socket path
|
||||
// (TMPDIR + "CoreFxPipe_" + name) inside the 104-byte sun_path limit on
|
||||
// macOS, whose default per-user TMPDIR is ~49 chars; the gateway PID keeps
|
||||
// the name collision-free across gateway restarts (NEXT-01).
|
||||
string pipeName = $"mxgw-{Environment.ProcessId}-{sessionUid}";
|
||||
string nonce = CreateNonce();
|
||||
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
|
||||
string clientCorrelationId = CreateClientCorrelationId(request.ClientSessionName, sessionId);
|
||||
@@ -484,11 +489,6 @@ public sealed class SessionManager : ISessionManager
|
||||
: timeout;
|
||||
}
|
||||
|
||||
private static string CreateSessionId()
|
||||
{
|
||||
return $"session-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string CreateNonce()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
|
||||
@@ -21,6 +21,14 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
||||
public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName =
|
||||
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
|
||||
|
||||
/// <summary>
|
||||
/// Conveys MxGateway:Worker:WriteCompletionWaitMilliseconds to the worker:
|
||||
/// the bounded wait for the OnWriteComplete callback on
|
||||
/// WriteSecured/WriteSecured2 replies. 0 disables the wait.
|
||||
/// </summary>
|
||||
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
|
||||
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
||||
|
||||
private readonly IWorkerProcessFactory _processFactory;
|
||||
private readonly IWorkerStartupProbe _startupProbe;
|
||||
private readonly GatewayMetrics _metrics;
|
||||
@@ -175,6 +183,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
||||
startInfo.Environment[WorkerNonceEnvironmentVariableName] = request.Nonce;
|
||||
startInfo.Environment[WorkerPipeConnectAttemptTimeoutEnvironmentVariableName] =
|
||||
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
|
||||
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<!-- Dev-box channel for MxGateway:Ldap:ServiceAccountPassword (SEC-36): user-secrets
|
||||
are loaded automatically in the Development environment and live under the user
|
||||
profile, outside the tree, so the shared GLAuth bind credential is never committed. -->
|
||||
<UserSecretsId>mxaccessgw-server</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -11,7 +15,7 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
||||
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.4.1" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.2.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user