Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ed0468588 | |||
| 328d662315 | |||
| e541339c07 | |||
| f84e0c3474 | |||
| a60c1e3f66 | |||
| 3081b80efc | |||
| 117936e6fd | |||
| c47b9d7b02 | |||
| 327493f077 | |||
| e57d864ab2 | |||
| 5539ec8542 | |||
| 73e54e252d | |||
| 70d959bd9b | |||
| 0c5b796e2e | |||
| 47dc9d865f | |||
| 4f757e3c0c | |||
| 2f0ee4c961 | |||
| 0859d47f75 | |||
| 7ea8358c06 | |||
| a5944bbe5d | |||
| 04bce3ff9f | |||
| 9572045787 | |||
| 7e1af37eb1 | |||
| 05009d7370 | |||
| f4dc11bae4 | |||
| c3b466e13d | |||
| 792e3f9445 | |||
| ae281d06bb | |||
| 3ca2799c90 | |||
| 459a88b3e7 | |||
| 437ab65fc1 | |||
| 679562e5ed | |||
| dbf550da8b | |||
| 3965a7741e | |||
| abb2cfb84b | |||
| 4e0d8ccfed | |||
| a935aa8b7c | |||
| 9912389fa1 | |||
| f1129b969d | |||
| c51b6f9ce4 | |||
| e39972357b | |||
| 9ad17e2964 | |||
| ef0a883a81 | |||
| 62ba5e9487 | |||
| 136614be94 | |||
| a912bffad5 | |||
| 9bdb899774 | |||
| e5c704de69 | |||
| 4e520f9c0c | |||
| 2eb81379e4 | |||
| ddd5721082 | |||
| 3775f6bf3b | |||
| cdfad420bb | |||
| 330e665f6b | |||
| 5e01ad9c22 | |||
| 77a9108673 | |||
| 192607ab8c | |||
| ba82afe669 | |||
| fe7d1ce1ec | |||
| b8a6695612 | |||
| 6f9188bc8d | |||
| a276f46f81 | |||
| 572b268d81 | |||
| 4c093a64fa | |||
| f47bbaea95 | |||
| c463b49f46 | |||
| 87f86503ef | |||
| e912ef960c | |||
| c4e7ddea70 | |||
| 6bfa4fe884 | |||
| b4a7bac4c0 | |||
| 6df373ae4c | |||
| fe44e3c18a | |||
| 523f944f3e | |||
| c33f1e6047 | |||
| 92cc4688e6 | |||
| a155554038 | |||
| 68f905a344 | |||
| 5abc222c72 | |||
| da3aa7b0b2 | |||
| f0ec068430 | |||
| 1a1d14a9fd | |||
| b2448510ac | |||
| 75610e3f55 | |||
| 5032166106 | |||
| 76a042d663 | |||
| 4a19854eb9 | |||
| a4467e23ef | |||
| eacfeff9fb | |||
| b4bc2df015 | |||
| fd2a0ac4c7 | |||
| 555e4be51f | |||
| 1d8c0d83c4 | |||
| 6600f2a7bd | |||
| 803a207ad2 | |||
| 97e583e96b | |||
| eaf479349d | |||
| 83a4d41fce | |||
| 0d6193cdc4 | |||
| 8cd3e1c20e | |||
| 5c28458624 | |||
| 0b389f5a97 | |||
| 108c4bb118 | |||
| cf54a278e1 | |||
| 81b2aacfe2 | |||
| 5932fe2fd3 | |||
| 310dfab8b4 | |||
| ba157b4b4f | |||
| 87e22dd529 | |||
| d9eaf4b056 | |||
| 2c5c5e5c7e | |||
| b3ebf583ad | |||
| edb812d859 | |||
| 795eee72e3 | |||
| 615b487a77 | |||
| 382861c602 | |||
| ba2b936609 | |||
| 7fc1955287 | |||
| 54480dde61 | |||
| 581b541801 | |||
| d3cb311aae | |||
| 186d03e5cc | |||
| 6bae5ea3a3 |
@@ -45,6 +45,7 @@ build/
|
|||||||
out/
|
out/
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
install/
|
||||||
|
|
||||||
# .NET
|
# .NET
|
||||||
**/bin/
|
**/bin/
|
||||||
@@ -146,3 +147,8 @@ generated-scratch/
|
|||||||
|
|
||||||
# Keep empty directories with .gitkeep files when needed
|
# Keep empty directories with .gitkeep files when needed
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|
||||||
|
# Documentation review artifacts (CommentChecker output)
|
||||||
|
*-docs-issues.md
|
||||||
|
*-docs-fixed.md
|
||||||
|
*-docs-final.md
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ The worker must do all MXAccess COM calls on its dedicated STA thread, and the S
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Full solution build (gateway, worker, contracts, tests)
|
# Full solution build (gateway, worker, contracts, tests)
|
||||||
dotnet build src/MxGateway.sln
|
dotnet build src/ZB.MOM.WW.MxGateway.slnx
|
||||||
|
|
||||||
# Worker must be built x86 — the gateway looks for MxGateway.Worker.exe under bin\x86
|
# Worker must be built x86 — the gateway looks for MxGateway.Worker.exe under bin\x86
|
||||||
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
|
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
|
||||||
@@ -29,10 +29,10 @@ dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
|
|||||||
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86
|
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86
|
||||||
|
|
||||||
# Run gateway locally (defaults bound under MxGateway:* in src/MxGateway.Server/appsettings.json)
|
# Run gateway locally (defaults bound under MxGateway:* in src/MxGateway.Server/appsettings.json)
|
||||||
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
|
||||||
|
|
||||||
# API-key admin CLI (same exe, "apikey" subcommand)
|
# API-key admin CLI (same exe, "apikey" subcommand)
|
||||||
dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
|
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create-key --key-id dev --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Single test by name (xUnit `--filter`):
|
Single test by name (xUnit `--filter`):
|
||||||
@@ -54,7 +54,7 @@ Live LDAP tests use `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`. See `docs/GatewayTesting.
|
|||||||
|
|
||||||
Each language client is in `clients/<lang>/` with its own README. They all consume the shared `.proto` files in `src/MxGateway.Contracts/Protos`:
|
Each language client is in `clients/<lang>/` with its own README. They all consume the shared `.proto` files in `src/MxGateway.Contracts/Protos`:
|
||||||
|
|
||||||
- `clients/dotnet`: `dotnet build clients/dotnet/MxGateway.Client.sln`
|
- `clients/dotnet`: `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx`
|
||||||
- `clients/python`: `python -m pip install -e ".[dev]"; python -m pytest`
|
- `clients/python`: `python -m pip install -e ".[dev]"; python -m pytest`
|
||||||
- `clients/rust`: `cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings`
|
- `clients/rust`: `cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings`
|
||||||
- `clients/java`: `gradle test` (Java 21)
|
- `clients/java`: `gradle test` (Java 21)
|
||||||
@@ -77,7 +77,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
|
|||||||
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
|
||||||
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
|
||||||
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
|
||||||
- **Generated code** under `src/MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
- **Generated code** under `src/MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
|
||||||
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
|
||||||
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
|
| Contracts or `.proto` files | regenerate generated code, then build gateway, worker, and every generated client touched by the contract |
|
||||||
| Gateway server, sessions, workers, gRPC, dashboard, metrics | `dotnet build src/MxGateway.Server` and run affected gateway / fake-worker tests |
|
| Gateway server, sessions, workers, gRPC, dashboard, metrics | `dotnet build src/MxGateway.Server` and run affected gateway / fake-worker tests |
|
||||||
| Worker IPC, STA, MXAccess, conversion | `dotnet build src/MxGateway.Worker -p:Platform=x86` and run worker tests |
|
| Worker IPC, STA, MXAccess, conversion | `dotnet build src/MxGateway.Worker -p:Platform=x86` and run worker tests |
|
||||||
| .NET client | `dotnet build clients/dotnet/MxGateway.Client.sln` and run its tests |
|
| .NET client | `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` and run its tests |
|
||||||
| Go client | `gofmt`, `go build ./...`, `go test ./...` from `clients/go` |
|
| Go client | `gofmt`, `go build ./...`, `go test ./...` from `clients/go` |
|
||||||
| Rust client | `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings` from `clients/rust` |
|
| Rust client | `cargo fmt`, `cargo check --workspace`, `cargo test --workspace`, `cargo clippy --all-targets -- -D warnings` from `clients/rust` |
|
||||||
| Python client | `python -m pytest` from `clients/python` |
|
| Python client | `python -m pytest` from `clients/python` |
|
||||||
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
@@ -114,9 +114,9 @@ External analysis sources referenced by design docs:
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
|
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`.
|
||||||
|
|
||||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
|
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Administrator` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled.
|
||||||
|
|
||||||
## Process / Platform Notes
|
## Process / Platform Notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# MXAccess Gateway — Documentation Audit Findings
|
||||||
|
|
||||||
|
Synthesized from the 13 audit fragments under `docs/audit/fragments/`. This report drives the fix phase (Tasks 15–22). It is read-only with respect to code and the audited docs; the only artifact produced is this file.
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
Total findings: **186** across 13 clusters.
|
||||||
|
|
||||||
|
### Counts by verdict
|
||||||
|
|
||||||
|
| Verdict | Count |
|
||||||
|
|---|---|
|
||||||
|
| accurate | 109 |
|
||||||
|
| stale | 27 |
|
||||||
|
| wrong | 33 |
|
||||||
|
| unverifiable | 6 |
|
||||||
|
| gap | 24 |
|
||||||
|
|
||||||
|
(Note: a small number of cluster-08 entries are verdict-tagged `accurate` in the fragment body while the prose flags a phrasing nuance; they are counted as `accurate`.)
|
||||||
|
|
||||||
|
### Counts by severity
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
|---|---|
|
||||||
|
| high | 33 |
|
||||||
|
| medium | 33 |
|
||||||
|
| low | 120 |
|
||||||
|
|
||||||
|
### Per-cluster table
|
||||||
|
|
||||||
|
| Cluster | #high | #med | #low | #gap (any sev) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 01 Architecture | 3 | 4 | 33 | 0 |
|
||||||
|
| 02 Worker | 5 | 6 | 30 | 4 |
|
||||||
|
| 03 Sessions | 2 | 8 | 18 | 6 |
|
||||||
|
| 04 Auth | 11 | 7 | 14 | 5 |
|
||||||
|
| 05 Dashboard | 7 | 9 | 8 | 6 |
|
||||||
|
| 06 Config | 2 | 3 | 27 | 4 |
|
||||||
|
| 07 Contracts/gRPC | 3 | 3 | 22 | 3 |
|
||||||
|
| 08 Galaxy | 5 | 3 | 41 | 6 |
|
||||||
|
| 09 Alarms | 7 | 6 | 22 | 8 |
|
||||||
|
| 10 Testing | 2 | 0 | 30 | 2 |
|
||||||
|
| 11 Clients | 7 | 5 | 18 | 3 |
|
||||||
|
| 12 Style guides | 3 | 1 | 10 | 0 |
|
||||||
|
| 13 History/Plans | 0 | 1 | 21 | 0 |
|
||||||
|
|
||||||
|
(`#high/#med/#low` count all findings at that severity in the cluster; `#gap` counts gap-verdict findings regardless of severity, shown separately because gaps are additive work rather than corrections.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Global substitutions table
|
||||||
|
|
||||||
|
Mechanical string replacements that recur across multiple docs or are pure find-and-replace. The "applies to" list contains **only** files the fragment evidence shows actually contain the old string. CLAUDE.md is a living doc and is listed explicitly where the evidence targets it. Per the audit rules, design-history / plan docs (cluster 13) are **excluded** from these applies-to lists — their term occurrences are historical records, not corrected here (only their broken internal cross-refs are fixed, in Task 22).
|
||||||
|
|
||||||
|
| old string | new string | claim_type | applies to (doc list) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Admin` (dashboard role value) | `Administrator` | term | CLAUDE.md (L119, L234-evidence); docs/GatewayConfiguration.md (L55, L156); docs/DashboardInterfaceDesign.md (role labels where used as config value); docs/Authorization.md (L215 — judgment, see Task 18) |
|
||||||
|
| cookie `__Host-MxGatewayDashboard` | `MxGatewayDashboard` | config-key/term | CLAUDE.md (L119); docs/GatewayDashboardDesign.md (L420–422) |
|
||||||
|
| `src/MxGateway.sln` | `src/ZB.MOM.WW.MxGateway.slnx` | path | CLAUDE.md (L22) |
|
||||||
|
| `src/MxGateway.Server/MxGateway.Server.csproj` (short project paths in layout/commands) | `src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` (and sibling fully-qualified names) | path | gateway.md (L737–769); CLAUDE.md (L35, L248-evidence) |
|
||||||
|
| `clients/dotnet/MxGateway.Client.sln` | `clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` | path | CLAUDE.md (L57, L93); docs/ClientPackaging.md (L51–52) |
|
||||||
|
| `clients/python/src/mxgateway/generated` | `clients/python/src/zb_mom_ww_mxgateway/generated` | path | docs/ClientProtoGeneration.md (L80, L74–81 table, L145); docs/ClientLibrariesDesign.md (L410); docs/ClientPackaging.md (L159–160); docs/style-guides/PythonStyleGuide.md (L27–29 parent path) |
|
||||||
|
| Python package `mxaccess-gateway-client` | `zb-mom-ww-mxaccess-gateway-client` | config-key | docs/ClientPackaging.md (L159–160); clients/python/PythonClientDesign.md (L215) |
|
||||||
|
| Python module `mxgateway_cli` | `zb_mom_ww_mxgateway_cli` | command/path | docs/ClientPackaging.md (L187); docs/style-guides/PythonStyleGuide.md (L27–29) |
|
||||||
|
| Python library package `mxgateway` (src dir) | `zb_mom_ww_mxgateway` | path | docs/style-guides/PythonStyleGuide.md (L27–29) |
|
||||||
|
| Gradle task `:mxgateway-cli:` | `:zb-mom-ww-mxgateway-cli:` | command | docs/GatewayTesting.md (L322–324); docs/ClientPackaging.md (L193–227) |
|
||||||
|
| Gradle task `:mxgateway-client:` | `:zb-mom-ww-mxgateway-client:` | command | docs/ClientPackaging.md (L193–227) |
|
||||||
|
| logger category `ZB.MOM.WW.MxGateway.Request` | `MxGateway.Request` | term | docs/Diagnostics.md (L165–166) |
|
||||||
|
| STA thread name `ZB.MOM.WW.MxGateway.Worker.STA` | `MxGateway.Worker.STA` | term | docs/WorkerSta.md (L23, L29); docs/MxAccessWorkerInstanceDesign.md (L254) |
|
||||||
|
| Java package root `com.dohertylan.mxgateway` | `com.zb.mom.ww.mxgateway` | config-key | docs/style-guides/JavaStyleGuide.md (L25) |
|
||||||
|
| Rust crate `mxgateway-client` (library crate name) | `zb-mom-ww-mxgateway-client` | term | docs/ClientPackaging.md (L116) |
|
||||||
|
| dashboard route prefix `/dashboard*` | `/` + `/sessions`, `/workers`, `/events`, `/alarms`, `/galaxy`, `/browse`, `/apikeys`, `/settings` | path | docs/GatewayProcessDesign.md (L249–255); docs/GatewayDashboardDesign.md (L289–345); docs/GalaxyRepository.md (L419–422) |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The scope-shorthand renames (`session`→`session:open`/`session:close`, `invoke`→`invoke:read`/`invoke:write`/`invoke:secure`, `event`→`events:read`, `metadata`→`metadata:read`) are **not** a single 1:1 mechanical substitution (one shorthand maps to multiple canonical scopes), so they are handled as judgment edits in Tasks 18/20, not in this table. The affected docs are gateway.md (L662–663), CLAUDE.md (L35, L117, L248-evidence), docs/Authentication.md (L99, L187–208).
|
||||||
|
- The `wwwroot/css/dashboard.css` → `site.css` rename is dashboard-cluster-specific (single doc family) and is handled in Task 19.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Out-of-prose-scope flags
|
||||||
|
|
||||||
|
These findings target **non-`.md`** files. They are real bugs but outside this prose audit. **Flag only — recommend separate fix.** Do not schedule them for doc-editing tasks.
|
||||||
|
|
||||||
|
| Finding ID | File | Issue | Severity |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F-10-2 | `clients/proto/fixtures/smoke/cross-language-smoke-matrix.json` | Every Java command entry uses `gradle :mxgateway-cli:run`; the Gradle subproject is `:zb-mom-ww-mxgateway-cli`. Verbatim execution fails; `CrossLanguageSmokeMatrixTests` does not check the literal task name, so it passes CI undetected. | high |
|
||||||
|
|
||||||
|
(No other fragment finding targets a non-`.md` artifact for an edit; `proto-inputs.json`, `appsettings.json`, source `.cs/.rs/.go/.gradle/.toml` etc. appear only as evidence, not as edit targets.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Per-doc findings
|
||||||
|
|
||||||
|
Findings grouped by DOC, ordered high→low severity within each doc. IDs are `F-<cluster#>-<n>` numbered in fragment order within the cluster.
|
||||||
|
|
||||||
|
### gateway.md
|
||||||
|
|
||||||
|
- **F-01-13** — L231–248 — wrong/high — `WorkerEnvelope` proto block (field type/numbers/names). EVIDENCE: `mxaccess_worker.proto` has `string correlation_id = 4` (not uint64); body fields `gateway_hello=10 … worker_fault=20`; names differ (`command`→`worker_command`, `event`→`worker_event`); missing `worker_shutdown_ack=17`. FIX: replace the block with actual proto content.
|
||||||
|
- **F-01-1** — L737–769 — stale/medium — short project names in layout. FIX: use fully-qualified `src/ZB.MOM.WW.MxGateway.*` names (see substitutions).
|
||||||
|
- **F-01-2** — L898–913 — stale/medium — session state machine missing `Handshaking`. FIX: insert `-> Handshaking` between `WaitingForPipe` and `InitializingWorker`.
|
||||||
|
- **F-01-12** — L301–314 — stale/medium — second session state-machine diagram also missing `Handshaking`. FIX: same insertion in both diagrams.
|
||||||
|
- **F-01-3** — L119–121 — stale/medium — scope rejection lists shorthand scope names. FIX: canonical scope strings (judgment, see Task 18 note).
|
||||||
|
- **F-01-4** — L119–121 — stale/low — dashboard route list omits `/browse` and `/login`. FIX: add them.
|
||||||
|
- **F-01 accurate set** — multiple (L88–94, 108, 110–122, 129–130, 162–210, 266–273, 646–650, 1023–1025, 2–19) — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/GatewayProcessDesign.md
|
||||||
|
|
||||||
|
- **F-01-7** — L249–255 — wrong/high — `/dashboard`-prefixed route table. FIX: replace with actual no-prefix routes (see substitutions).
|
||||||
|
- **F-01-8** — L689 — stale/low — `Dashboard:AllowAnonymousLocalhost` missing `MxGateway:` root prefix. FIX: standardize to `MxGateway:Dashboard:AllowAnonymousLocalhost`.
|
||||||
|
- **F-01-9** — L854–855 — accurate/low — worker `ExecutablePath` default (separator style only). Flag only.
|
||||||
|
- **F-01 accurate set** — L62–93, 100–105, 223–229, 291–299, 408–410, 420–475, 527–530, 713–719, 864–893 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/DesignDecisions.md
|
||||||
|
|
||||||
|
- **F-01-6** — L360–363 — wrong/high — claims dashboard auth is "API-key-backed dashboard authentication with `admin` scope." EVIDENCE: `DashboardAuthenticator.cs` is LDAP-backed with `GroupToRole`. FIX: rewrite to LDAP-backed + `GroupToRole`→`Admin`/`Viewer`; keep `AllowAnonymousLocalhost` note.
|
||||||
|
- **F-01-10** — L36 — unverifiable/low — interop assembly version/PKT not hard-coded in repo. Flag only.
|
||||||
|
- **F-01-11** — L36–48 — accurate/low — COM class/CLSID/ProgID/paths. Flag only.
|
||||||
|
- **F-01-14** — L55 — accurate/low — `ArchestrA.MXAccess.dll` casing. Flag only.
|
||||||
|
- **F-01 accurate set** — L85–95, 217–225 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/WorkerSta.md
|
||||||
|
|
||||||
|
- **F-02-1** — L23–31 — wrong/medium — STA thread name `ZB.MOM.WW.MxGateway.Worker.STA`. FIX: `MxGateway.Worker.STA` (prose + snippet) (substitution).
|
||||||
|
- **F-02-3** — L144 — wrong/medium — `InvokeAsync` throws `InvalidOperationException`. EVIDENCE: throws `StaRuntimeShutdownException` (subtype). FIX: name the subtype and explain why the distinction matters.
|
||||||
|
- **F-02-19** — L141–148 — stale/medium — shutdown drain sequence implies single post-stop drain. EVIDENCE: `CancelQueuedCommands` runs inside `ThreadMain` finally before `stoppedEvent.Set()`, and again in `Shutdown()`; drain happens twice. FIX: revise steps 3–4.
|
||||||
|
- **F-02-12** — L14 — stale/low — "Bounded asynchronous queue." EVIDENCE: plain `Queue<T>` under lock with async drain loop. FIX: "Bounded queue with an async drain loop."
|
||||||
|
- **F-02 accurate set** — L34, 56, 63–78, 82–99, 108, 149 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/MxAccessWorkerInstanceDesign.md
|
||||||
|
|
||||||
|
- **F-02-4** — L122 — wrong/high — `Success` (exit 0) = "bootstrap options valid." EVIDENCE: actual meaning "pipe session ran to a clean close." FIX: correct Success row; note `WorkerBootstrapResult.Succeeded` is a parse-phase gate distinct from exit 0.
|
||||||
|
- **F-02-5** — L119–128 — stale/high — exit-code table missing codes 5 (`PipeConnectionFailed`) and 6 (`ProtocolViolation`). FIX: add both rows.
|
||||||
|
- **F-02-6** — L134–160 — stale/high — component tree class names wrong (`WorkerHost`→`WorkerApplication`, `PipeClient`→`WorkerPipeClient`, `FrameReader/Writer`→`WorkerFrameReader/Writer`, `WorkerProtocol`→`WorkerContractInfo`, `StaCommandQueue`→`StaCommandDispatcher`, `MessagePump`→`StaMessagePump`, `StaWatchdog`→`WorkerPipeSession`, `MxAccessCommandDispatcher`→`MxAccessCommandExecutor`, `SafeArrayConverter`→part of `VariantConverter`, `StatusProxyConverter`→`MxStatusProxyConverter`, `HResultMapper`→`HResultConverter`). FIX: rewrite tree.
|
||||||
|
- **F-02-15** — L97 — wrong/high — `MXGATEWAY_WORKER_LOG_CONTEXT` env var documented. EVIDENCE: not read anywhere. FIX: remove or mark unimplemented.
|
||||||
|
- **F-02-16** — L86–99 — wrong/high — same `MXGATEWAY_WORKER_LOG_CONTEXT` in bootstrap sequence. FIX: flag-only duplicate of F-02-15.
|
||||||
|
- **F-02-22** — L134–160 — gap/high — no alarm subsystem in component tree. FIX: add "Alarm Subsystem" section (consumer, poll loop, dispatcher, sink).
|
||||||
|
- **F-02-2** — L254 — wrong/medium — STA thread name. FIX: `MxGateway.Worker.STA` (substitution).
|
||||||
|
- **F-02-20** — L134–160 — stale/medium — `MxAccess` subtree class names (`MxAccessCommandDispatcher` does not exist; add `MxAccessStaSession`, `MxAccessCommandExecutor`, alarm sinks). FIX: update.
|
||||||
|
- **F-02-23** — L336–338 — gap/medium — event-sink subscription list omits alarm events. FIX: add `MxAccessAlarmEventSink`.
|
||||||
|
- **F-02-18** — L368–375 — stale/low — `MxAccessEventQueue.Enqueue` also throws `MxAccessEventQueueOverflowException`. FIX: note thrown exception.
|
||||||
|
- **F-02-26** — L151 — accurate/low — `MxAccessSession` exists. Flag only.
|
||||||
|
- **F-02 accurate set** — L271–286, 656–660 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/WorkerBootstrap.md
|
||||||
|
|
||||||
|
- **F-02-7** — L146 — stale/medium — stderr/stdout-capture rationale. EVIDENCE: launcher redirects neither stream. FIX: replace rationale; the env-var-secrecy reason is the accurate one.
|
||||||
|
- **F-02-25** — L5–6 — stale/low — "short-lived child." FIX: "per-session child process."
|
||||||
|
- **F-02 accurate set** — L7–8, 48–54, 105, 113–120, 155–159, 181–193 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/WorkerConversion.md
|
||||||
|
|
||||||
|
- **F-02-21** — L1–262 — gap/medium — inverse projection (`ConvertToComValue`/`ConvertToComArray`, write path) undocumented. FIX: add "Inverse projection for COM writes" section.
|
||||||
|
- **F-02-11** — L225 — stale/low — engine-error ranges implied contiguous; gaps exist (35,45,46 / 58,59). FIX: "selected detail codes in the ranges …".
|
||||||
|
- **F-02 accurate set** — L17–18, 112–135, 178 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/WorkerFrameProtocol.md / docs/WorkerProcessLauncher.md
|
||||||
|
- All findings accurate/low (F-02 frameproto and launcher accurate set: WorkerFrameProtocol L14–53; WorkerProcessLauncher L18–64). Flag only.
|
||||||
|
|
||||||
|
### docs/Sessions.md
|
||||||
|
|
||||||
|
- **F-03-22** — gap/high — orphan cleanup (`OrphanWorkerCleanupHostedService` → `OrphanWorkerTerminator.TerminateOrphans` on startup, best-effort) undocumented. FIX: add "Gateway Restart / Orphan Cleanup" section.
|
||||||
|
- **F-03-21** — L230 — wrong/high — invents metric names `KillCount`/`ShutdownCount`. EVIDENCE: actual counter is `mxgateway.workers.killed`. FIX: replace with real counter via `GatewayMetrics.WorkerKilled`.
|
||||||
|
- **F-03-1** — L9 — wrong/medium — "All four interfaces" (only three exist) and omits `SessionLeaseMonitorHostedService`. FIX: "three interfaces"; list two hosted services.
|
||||||
|
- **F-03-2** — L265–276 — stale/medium — DI snippet omits `SessionLeaseMonitorHostedService`. FIX: add the registration line.
|
||||||
|
- **F-03-3** — L232–259 — stale/medium — `ShutdownAsync` snippet predates Server-045/046; fallback now routes via `KillWorkerAsync`. FIX: replace snippet.
|
||||||
|
- **F-03-4** — L55–59 — stale/medium — `KillWorkerAsync` no longer calls `GatewaySession.KillWorker` directly; now `KillWorkerWithCloseGateAsync` (acquires `_closeLock`). FIX: update.
|
||||||
|
- **F-03-12** — L163–188 — stale/medium — open-failure rollback order omits conditional `SessionRemoved()` (Server-006). FIX: note the conditional metric call before `ReleaseSessionSlot`.
|
||||||
|
- **F-03-19** — L230 — stale/medium — `GatewaySession.KillWorker` no longer the entry point from `SessionManager`. FIX: clarify `KillWorkerWithCloseGateAsync` is the path.
|
||||||
|
- **F-03-23** — gap/medium — `AllowMultipleEventSubscribers=true` rejected at startup by `GatewayOptionsValidator`. FIX: note startup-validation refusal.
|
||||||
|
- **F-03-7** — L265 — wrong/medium — "the hosted service" (singular). FIX: "two hosted services."
|
||||||
|
- **F-03-20** — L279 — stale/low — registration-order reasoning. FIX: note two hosted services + DI ordering caveat.
|
||||||
|
- **F-03-24** — gap/low — `_items` registration dictionary undocumented. FIX: add paragraph.
|
||||||
|
- **F-03-25** — gap/low — `MaxPendingCommandsPerSession` (128) cap undocumented. FIX: add note.
|
||||||
|
- **F-03-26** — gap/low — `KillWorkerWithCloseGateAsync` unmentioned. FIX: mention in Close section.
|
||||||
|
- **F-03 accurate set** — L15–127, 134–227, 195–197, 197 (lease/sweep) — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/Authentication.md
|
||||||
|
|
||||||
|
- **F-04-1** — L253–271 — stale/high — Registration block is pre-migration; types now from `ZB.MOM.WW.Auth.ApiKeys` via `AddZbApiKeyAuth`. FIX: replace block; remove "registers the migration hosted service" claim.
|
||||||
|
- **F-04-9** — L187–208 — wrong/high — CLI example `--scopes read,write` + subcommand `create`. EVIDENCE: scopes invalid; subcommand is `create-key`. FIX: canonical scopes (e.g. `invoke:read,invoke:write`), `create-key`.
|
||||||
|
- **F-04-2** — L53–68 — stale/medium — `ApiKeySecretHasher` etc. are shared-library types; return type `ApiKeyVerification` not `ApiKeyVerificationResult`. FIX: clarify ownership + type name.
|
||||||
|
- **F-04-3** — L72–98 — stale/medium — `ApiKeyVerifier` types/return shapes from shared package. FIX: `ApiKeyVerification`; note shared lib.
|
||||||
|
- **F-04-5** — L126–133 — stale/medium — schema table omits `audit_event` table; `api_key_audit` no longer written. FIX: add fourth table + note.
|
||||||
|
- **F-04-4** — L108–122 — stale/low — `AuthSqliteConnectionFactory` ownership/`ApiKeyOptions.SqlitePath`. FIX: clarify.
|
||||||
|
- **F-04-6** — L134–153 — stale/low — `SqliteApiKeyStore` from shared package. FIX: label code block as shared-lib.
|
||||||
|
- **F-04-7** — L156–164 — stale/low — `SqliteApiKeyAdminStore` shared; CLI uses `ApiKeyAdminCommands`. FIX: clarify.
|
||||||
|
- **F-04-8** — L165–183 — stale/low — `SqliteAuthStoreMigrator` etc. shared. FIX: clarify.
|
||||||
|
- **F-04-10** — L229–248 — stale/low — `ApiKeyScopeSerializer` shared. FIX: note.
|
||||||
|
- **F-04-gap-3** — gap/medium — `api_key_audit` unused at runtime; all audit → `audit_event`. FIX: document.
|
||||||
|
- **F-04-gap-2** — gap/medium — 8-hour cookie idle timeout + 30-min hub token undocumented. FIX: add.
|
||||||
|
- **F-04-gap-1** — gap/medium — `MxGateway:Dashboard:CookieName` override undocumented. FIX: document.
|
||||||
|
- **F-04-gap-4** — gap/low — `RequireHttpsCookie` undocumented. FIX: reference.
|
||||||
|
- **F-04-gap-5** — gap/low — `ZbClaimTypes`/`ZbCookieDefaults` undocumented. FIX: brief note.
|
||||||
|
- **F-04 accurate set** — L1–30, 110, 189–208, 220–225 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/Authorization.md
|
||||||
|
|
||||||
|
- **F-04-11** — L107–113 — stale/high — scope resolver block omits `BrowseChildrenRequest => MetadataRead`. FIX: add it.
|
||||||
|
- **F-04-12** — L212 — stale/high — scope catalog table omits `GalaxyRepository.BrowseChildren`. FIX: add to `MetadataRead` row.
|
||||||
|
- **F-04-18** — L205–215 — stale/high — same catalog gap (`BrowseChildren`). FIX: as above.
|
||||||
|
- **F-04-13** — L260–270 — stale/medium — registration block omits `IConstraintEnforcer`/`ConstraintEnforcer` and `GrpcServiceOptions` size limits. FIX: add.
|
||||||
|
- **F-04-16** — L215 — stale/medium — claims `GatewayScopes.Admin` referenced by `DashboardAuthenticator`. EVIDENCE: dashboard role `Administrator` and gRPC scope `admin` are separate. FIX: correct/remove the claim.
|
||||||
|
- **F-04-14** — L273 — stale/low — "three classes" → four (adds `ConstraintEnforcer`). FIX: update.
|
||||||
|
- **F-04 accurate set** — L85, 94–116 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### glauth.md
|
||||||
|
|
||||||
|
- **F-04-15** — L63–66 — wrong/high — `LdapOptions.RequiredGroup` defaults to `GwAdmin`. EVIDENCE: no `RequiredGroup` exists; membership enforced via `GroupToRole`. FIX: rewrite.
|
||||||
|
- **F-04-17** — L181–182 — wrong/high — "strips to `GwAdmin` and matches against `RequiredGroup`." FIX: "looks up the short RDN in `GroupToRole`."
|
||||||
|
- **F-04-19** — L113–136 — wrong/high — YAML keys `useTls`/`allowInsecureLdap`/`userNameAttribute`. EVIDENCE: actual `Transport`/`AllowInsecure`/`UserNameAttribute`(default `cn`); section header `MxGateway:Ldap`. FIX: rewrite YAML.
|
||||||
|
- **F-04-21** — L261–269 — wrong/high — AD cheat-sheet `UseTls`/`AllowInsecureLdap`. EVIDENCE: renamed `Transport`/`AllowInsecure`. FIX: rename rows.
|
||||||
|
- **F-04-20** — L128 — wrong/medium — `userNameAttribute: "uid"`. EVIDENCE: default is `cn`. FIX: change to `cn` + note.
|
||||||
|
- **F-04-22** — L70–74 — accurate/low — Task 1.7 role note. Flag only.
|
||||||
|
- **F-04-23** — L21–26 — accurate/low — connection details. Flag only.
|
||||||
|
|
||||||
|
### CLAUDE.md (auth-related judgment fixes — Task 18)
|
||||||
|
|
||||||
|
- **F-04-24** — L119 — wrong/high — cookie `__Host-MxGatewayDashboard` and role `Admin`. FIX: `MxGatewayDashboard` + `Administrator` (substitutions).
|
||||||
|
- **F-04-25** — L119 — wrong/high — LDAP groups map to `Admin`. FIX: `Administrator`.
|
||||||
|
- **F-04-26** — L35 — wrong/high — apikey example `create --scopes session,invoke,event,metadata,admin`. FIX: `create-key` + canonical scopes.
|
||||||
|
- **F-04-27** — L117 — wrong/high — scopes shorthand `session, invoke, event, metadata, admin`. FIX: canonical scope strings (SQLite path is correct, keep).
|
||||||
|
|
||||||
|
### docs/DashboardInterfaceDesign.md
|
||||||
|
|
||||||
|
- **F-05-1** — L39–57 — stale/high — `dashboard-shell`/`dashboard-navbar` HTML skeleton. EVIDENCE: now `ThemeShell` side rail. FIX: replace skeleton/prose.
|
||||||
|
- **F-05-2** — L115–123 — stale/high — five flat nav labels incl. "Overview." EVIDENCE: eight items in three groups; home is "Dashboard." FIX: update.
|
||||||
|
- **F-05-3** — L63–79 — wrong/high — `--mxgw-*` CSS tokens. EVIDENCE: none exist; all via theme kit tokens. FIX: remove table; note theme-kit tokens.
|
||||||
|
- **F-05-7** — L191–200 — wrong/high — Bootstrap `text-bg-*` badge mapping. EVIDENCE: `StatusBadge` delegates to `StatusPill` with `StatusState`. FIX: replace with `StatusState` vocabulary.
|
||||||
|
- **F-05-4** — L87–97 — stale/medium — typography values. FIX: h1 1.15rem/600, agg-label 0.68rem/600, agg-value 1.5rem/600 ink.
|
||||||
|
- **F-05-gap-2** — gap/medium — new StatusBadge states (`Active`/`Stale`/`Degraded`/`Unavailable`, `Closed`→Idle) undocumented. FIX: document full mapping.
|
||||||
|
- **F-05-5** — L99–111 — stale/low — spacing/radius. FIX: 0.85rem small-screen padding, 8px radius, full-border cards.
|
||||||
|
- **F-05-6** — L153–168 — stale/low — `metric-grid` `auto-fit, 12rem`. EVIDENCE: `auto-fill, 11rem`. FIX: update.
|
||||||
|
- **F-05-8** — L229–245 — stale/low — `.dashboard-content` breakpoint. EVIDENCE: `.page { padding: 0.85rem }`. FIX: update.
|
||||||
|
|
||||||
|
### docs/GatewayDashboardDesign.md
|
||||||
|
|
||||||
|
- **F-05-11** — L507–510 — wrong/high — `wwwroot/css/dashboard.css`. EVIDENCE: file is `site.css`; App.razor loads `<ThemeHead/>`/`<ThemeScripts/>`; denied-page loads theme kit CSS. FIX: rename + add theme-kit loading.
|
||||||
|
- **F-05-13** — L420–422 — wrong/high — cookie `__Host-MxGatewayDashboard`. FIX: `MxGatewayDashboard` (substitution); note `CookieName` override.
|
||||||
|
- **F-05-gap-3** — gap/high — `ZB.MOM.WW.Theme 0.2.0` package + components undocumented. FIX: add "Theme Kit" section.
|
||||||
|
- **F-05-9** — L78–110 — stale/medium — component tree: `DashboardLayout.razor` → `MainLayout.razor`/`LoginLayout.razor`; note `StatusBadge`→`StatusPill`; add `BrowseTreeNodeView.razor`, `ConfirmDialog.razor`. FIX: update tree.
|
||||||
|
- **F-05-10** — L406–428 — stale/medium — `Novell.Directory.Ldap.NETStandard`. EVIDENCE: shared `ZB.MOM.WW.Auth.Ldap` via `AddZbLdapAuth`. FIX: replace.
|
||||||
|
- **F-05-12** — L289–306 — stale/medium — Browse page `/dashboard/browse`. EVIDENCE: `/browse`; `DashboardBrowseTreeBuilder` is static in `DashboardBrowseModel.cs`. FIX: route + clarify.
|
||||||
|
- **F-05-14** — L307–318 — stale/medium — Alarms `/dashboard/alarms` + data-source. EVIDENCE: `/alarms`; uses `IDashboardLiveDataService.QueryAlarmsAsync` poll loop, not `CurrentAlarms`. FIX: route + source.
|
||||||
|
- **F-05-15** — L337–345 — stale/medium — API keys `/dashboard/apikeys`. EVIDENCE: `/apikeys`. FIX: route.
|
||||||
|
- **F-05-16** — L387–391 — stale/medium — appends `api_key_audit`. EVIDENCE: `audit_event` via `IAuditWriter`. FIX: correct table.
|
||||||
|
- **F-05-17** — L68–69 — stale/medium — `GalaxySummaryCache`/`GalaxySummaryRefreshService`. EVIDENCE: `GalaxyHierarchyCache`/`GalaxyHierarchyRefreshService`. FIX: rename (config key correct).
|
||||||
|
- **F-05-gap-1** — gap/medium — `/login` served by Blazor `Login.razor`/`<LoginCard>`; POST `/login` minimal-API. FIX: add to auth section.
|
||||||
|
- **F-05-gap-4** — gap/medium — `CookieName`/`RequireHttpsCookie` config undocumented. FIX: add.
|
||||||
|
- **F-05-18** — L160–170 — accurate/low — `DashboardEventBroadcaster` is a follow-up stub. Flag only (add planned-follow-up note).
|
||||||
|
- **F-05-19** — L171–177 — accurate/low — `DashboardPageBase`. Flag only.
|
||||||
|
- **F-05-20** — L559–577 — stale/low — "local Bootstrap static assets." FIX: add theme-kit layer note.
|
||||||
|
- **F-05-21** — L463–465 — unverifiable/low — `Authentication:Mode = Disabled` bypass not found in Dashboard/. FIX: cross-check GatewayOptions.
|
||||||
|
- **F-05-gap-5** — gap/low — `ConfirmDialog.razor` + admin controls on list pages undocumented. FIX: add.
|
||||||
|
|
||||||
|
### docs/GatewayConfiguration.md
|
||||||
|
|
||||||
|
- **F-06-1** — L55–56 — wrong/high — GroupToRole example `"Admin"`. EVIDENCE: validator requires `"Administrator"`. FIX: change value.
|
||||||
|
- **F-06-2** — L156 — wrong/high — table desc says `Admin`. FIX: `Administrator`.
|
||||||
|
- **F-06-4** — L1–419 — gap/medium — `MxGateway:Ldap` section (11 keys) not documented. FIX: add `## Ldap Options` table.
|
||||||
|
- **F-06-7** — L14–77 — gap/medium — config-shape JSON omits `Ldap`. FIX: add block.
|
||||||
|
- **F-06 accurate set** — L15–69, 110, 164–206, 228, 346–354 (Authentication/Worker/Sessions/Events/Dashboard/Protocol/Galaxy/Alarms/TLS/policies/hubs/pipeline) — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/Diagnostics.md
|
||||||
|
|
||||||
|
- **F-06-3** — L165–166 — wrong/medium — logger category `ZB.MOM.WW.MxGateway.Request`. FIX: `MxGateway.Request` (substitution).
|
||||||
|
- **F-06-5** — gap/low — `GatewayLogRedactorSeam` unmentioned. FIX: add note.
|
||||||
|
- **F-06-6** — gap/low — `AuthStoreHealthCheck` unmentioned. FIX: add section.
|
||||||
|
- **F-06 accurate set** — L15–148, 181–188 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/Metrics.md
|
||||||
|
- All findings accurate/low (F-06 metrics accurate set: L8–192). Flag only.
|
||||||
|
|
||||||
|
### docs/Grpc.md
|
||||||
|
|
||||||
|
- **F-07-1** — L13,32 — wrong/high — "six RPCs"; omits `QueryActiveAlarms`. FIX: "seven"; add handler section.
|
||||||
|
- **F-07-2** — L148 — wrong/medium — "every `ProtocolStatusCode`" factory; missing `MxAccessFailure`. FIX: qualify or add.
|
||||||
|
- **F-07-4** — L227 — wrong/medium — "default policy" drops only the stream. EVIDENCE: default is `FailFast` (session faulted); stream-drop is `DisconnectSubscriber`. FIX: rewrite.
|
||||||
|
- **F-07 accurate set** — L9–26, 100–108, 141–196, 237–243 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/Contracts.md
|
||||||
|
|
||||||
|
- **F-07-gap-1** — gap/medium — `QueryActiveAlarms` RPC/messages undocumented. FIX: add paragraph.
|
||||||
|
- **F-07-gap-2** — gap/low — `AlarmFeedMessage`/`StreamAlarms` 3-phase protocol not in shape-level ref. FIX: add entry.
|
||||||
|
- **F-07-gap-3** — gap/low — reserved `session_id` + intentionally-unset `status` on Acknowledge messages. FIX: add note.
|
||||||
|
- **F-07 accurate set** — L4–5, 9–61, 68–81, 94, 107 — accurate/low — flag only (build command `src/ZB.MOM.WW.MxGateway.slnx` already correct).
|
||||||
|
|
||||||
|
### docs/ClientProtoGeneration.md
|
||||||
|
|
||||||
|
- **F-07-3** — L80,145 — wrong/high — Python generated path. FIX: `clients/python/src/zb_mom_ww_mxgateway/generated` (substitution).
|
||||||
|
- **F-07-5** — L74–81 — wrong/high — table Python row same wrong path (and L145). FIX: same.
|
||||||
|
- **F-07 accurate set** — L39–45, 55–61, 89–101, 119–125, 170–176 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/GalaxyRepository.md
|
||||||
|
|
||||||
|
- **F-08-21** — L403–404 — wrong/high — "All four Galaxy RPCs." EVIDENCE: five (adds `BrowseChildren`). FIX: "five."
|
||||||
|
- **F-08-31** — L420–422 — wrong/high — `/dashboard/galaxy` + `/dashboard`. EVIDENCE: `/galaxy`, `/`. FIX: route fixes (substitution).
|
||||||
|
- **F-08-32** — L419–420 — wrong/high — overview card "on `/dashboard`." EVIDENCE: `/`. FIX: route.
|
||||||
|
- **F-08-10** — L83–86 — wrong/medium — page-token encoding `(cache_sequence, parent_id, filter_signature, offset)`. EVIDENCE: `sequence:filterSignature:offset` with parent folded into signature. FIX: rewrite.
|
||||||
|
- **F-08-18** — L387 — wrong/medium — `CommandTimeoutSeconds` "applies to all three RPCs." EVIDENCE: five RPCs; applies to SQL commands. FIX: rephrase.
|
||||||
|
- **F-08-gap-1** — gap/medium — 5-minute `Stale` auto-degrade undocumented. FIX: add note.
|
||||||
|
- **F-08-gap-4** — gap/medium — `HierarchySql` category-ID filter + name map undocumented. FIX: add table.
|
||||||
|
- **F-08-gap-2** — gap/low — snapshot-restore publishes deploy event. FIX: note.
|
||||||
|
- **F-08-gap-3** — gap/low — initial refresh at startup. FIX: note.
|
||||||
|
- **F-08-gap-5** — gap/low — `data_type` table unmentioned. FIX: flag only.
|
||||||
|
- **F-08-gap-6** — gap/low — `gobject`/`template_definition` parent CASE logic. FIX: flag only.
|
||||||
|
- **F-08-acc-display** — L399–400 — unverifiable/low — connection-string field filtering (`DashboardConnectionStringDisplay` not in scope). Flag only — recommend verifying.
|
||||||
|
- **F-08 accurate set** — L3–4, 30–43, 110–119, 150–152, 178–179, 212–390 (most SQL/proto/cache claims) — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/AlarmClientDiscovery.md
|
||||||
|
|
||||||
|
- **F-09-7** — L758–762 — wrong/high — `WorkerAlarmRpcDispatcher` + "always routes through `AcknowledgeAlarmByName`." EVIDENCE: class is `GatewayAlarmMonitor.BuildAcknowledgeCommand`; routing is conditional (GUID→GUID path, name→by-name). FIX: rewrite.
|
||||||
|
- **F-09-30** — L761–762 — wrong/high — duplicate of above (`WorkerAlarmRpcDispatcher`, "always"). FIX: replace sentence with `GatewayAlarmMonitor` conditional routing.
|
||||||
|
- **F-09-5** — L604–605 — wrong/high — presents `AlarmAckByGUID` as the ack method before the E_NOTIMPL discovery. FIX: add forward-reference warning or reorder.
|
||||||
|
- **F-09-11** — L644–647 — wrong/high — boolean STATE mapping (`in_alarm`/`acked`). EVIDENCE: proto uses `AlarmConditionState` (Active/ActiveAcked/Inactive). FIX: replace with enum mapping.
|
||||||
|
- **F-09-28** — L750–756 — stale/high — "all acks must go through `AcknowledgeByName`." EVIDENCE: code still dispatches GUID path unguarded. FIX: add guard or stop GUID dispatch; document.
|
||||||
|
- **F-09-gap-1** — gap/high — public alarm RPCs (`AcknowledgeAlarm`/`StreamAlarms`/`QueryActiveAlarms`) + `MxGateway:Alarms:*` config never named. FIX: add cross-reference section.
|
||||||
|
- **F-09-gap-2** — gap/high — always-on `GatewayAlarmMonitor` broker architecture undocumented. FIX: add section.
|
||||||
|
- **F-09-gap-3** — gap/high — `AlarmFeedMessage` snapshot→`snapshot_complete`→transition protocol undocumented. FIX: document.
|
||||||
|
- **F-09-gap-6** — gap/high — `alarm_full_reference` parse contract (GUID vs `Provider!Group.Tag`) undocumented. FIX: document.
|
||||||
|
- **F-09-1** — L71–74 — wrong/medium — references nonexistent `AlarmClientConsumer.cs`. FIX: note retired/replaced by `WnWrapAlarmConsumer.cs`.
|
||||||
|
- **F-09-9** — L636–639 — wrong/medium — consumer "polls on a timer." EVIDENCE: no internal timer; `PollOnce()` driven by STA. FIX: correct.
|
||||||
|
- **F-09-10** — L641–643 — wrong/medium — proto name `AlarmAckCommand`. EVIDENCE: `AcknowledgeAlarmCommand`; interface `AcknowledgeByGuid`. FIX: correct names.
|
||||||
|
- **F-09-12** — L648–649 — wrong/medium — `condition_id` field. EVIDENCE: no such field; use `alarm_full_reference`. FIX: replace.
|
||||||
|
- **F-09-31** — L765–773 — stale/medium — internal `Timer`/`pollIntervalMilliseconds=0`. EVIDENCE: no timer/param. FIX: update.
|
||||||
|
- **F-09-6** — L750–756 — accurate/medium — `AlarmAckByGUID` E_NOTIMPL; code calls it without guard. FIX flag: document COMException risk.
|
||||||
|
- **F-09-gap-4** — gap/medium — reconcile loop undocumented. FIX: document cadence/purpose.
|
||||||
|
- **F-09-gap-5** — gap/medium — subscriber backpressure (2048, drop+reconnect) undocumented. FIX: document.
|
||||||
|
- **F-09-gap-7** — gap/medium — `ActiveAlarmSnapshot.current_state` collapse (UnackRtn/AckRtn→Inactive) undocumented. FIX: document.
|
||||||
|
- **F-09-2/3** — L71–88 — stale/low — historical `AlarmClientConsumer` probe notes. Flag only.
|
||||||
|
- **F-09-4** — L492 — stale/low — PR A.5 reference superseded. Flag only.
|
||||||
|
- **F-09-17** — L672–676 — stale/low — "PR A.5 tests" label. FIX: reference actual test files.
|
||||||
|
- **F-09-gap-8** — gap/low — `AlarmTransitionKind.Retrigger` defined but unused. FIX: note reserved.
|
||||||
|
- **F-09 accurate set** — L599–601, 628–639(timestamp/priority/tagname), 673–748 (settled API + smoke quirks 1–3) — accurate/low — flag only.
|
||||||
|
|
||||||
|
### docs/GatewayTesting.md
|
||||||
|
|
||||||
|
- **F-10-1** — L322–324 — wrong/high — `gradle :mxgateway-cli:installDist`. FIX: `:zb-mom-ww-mxgateway-cli:installDist` (substitution).
|
||||||
|
- **F-10-gap-1** — gap/low — `ResolveRepositoryRoot` failure mode undocumented. FIX: add note.
|
||||||
|
- **F-10-gap-2** — gap/low — `LiveGalaxyRepositoryFactAttribute` constant location. Flag only.
|
||||||
|
- **F-10 accurate set** — L10–390 (most claims) — accurate/low — flag only.
|
||||||
|
- (F-10-2 targets the JSON fixture — see Section 3, flag only.)
|
||||||
|
|
||||||
|
### docs/ClientBehaviorFixtures.md / docs/ParityFixtureMatrix.md / docs/CrossLanguageSmokeMatrix.md / docs/ToolchainLinks.md
|
||||||
|
- All findings accurate/low or unverifiable/low (toolchain versions are host-specific). Flag only.
|
||||||
|
|
||||||
|
### docs/ClientPackaging.md
|
||||||
|
|
||||||
|
- **F-11-1** — L51–52 — wrong/high — `.sln`. FIX: `.slnx` (substitution).
|
||||||
|
- **F-11-2** — L159–160 — wrong/high — Python package name + generated path. FIX: substitutions.
|
||||||
|
- **F-11-3** — L187 — wrong/high — `python -m mxgateway_cli`. FIX: `zb_mom_ww_mxgateway_cli` (substitution).
|
||||||
|
- **F-11-4** — L193–227 — wrong/high — Java subproject/task names. FIX: `:zb-mom-ww-mxgateway-*` (substitution).
|
||||||
|
- **F-11-12** — L116 — wrong/medium — Rust library crate `mxgateway-client`. FIX: `zb-mom-ww-mxgateway-client`.
|
||||||
|
- **F-11-gap-1** — gap/medium — `scripts/pack-clients.ps1` unmentioned. FIX: add "Packing all clients" section.
|
||||||
|
- **F-11-gap-2** — gap/low — `python -m build` vs `pip wheel`. FIX: note canonical build method.
|
||||||
|
|
||||||
|
### docs/ClientLibrariesDesign.md
|
||||||
|
|
||||||
|
- **F-11-8** — L410 — wrong/high — Python generated path. FIX: substitution.
|
||||||
|
|
||||||
|
### clients/rust/README.md
|
||||||
|
|
||||||
|
- **F-11-5** — L65 — wrong/high — `stream-alarms --session-id … --max-messages`. EVIDENCE: `--max-events`, no `--session-id`. FIX: correct command.
|
||||||
|
- **F-11-6** — L66 — wrong/high — `acknowledge-alarm --session-id … --alarm-reference`. EVIDENCE: `--reference`, no `--session-id`. FIX: correct command.
|
||||||
|
- **F-11 accurate set** — L83, 257–274 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### clients/go/README.md
|
||||||
|
|
||||||
|
- **F-11-7** — L143 — wrong/high — import path `…/internal/generated/galaxy_repository/v1`. EVIDENCE: flat `…/internal/generated`. FIX: drop suffix.
|
||||||
|
- **F-11 accurate set** — L39–40, 292–312 — accurate/low — flag only.
|
||||||
|
|
||||||
|
### clients/dotnet/DotnetClientDesign.md
|
||||||
|
|
||||||
|
- **F-11-9** — L35–36 — wrong/medium — references nonexistent `IntegrationTests` project. FIX: remove or mark "not yet created."
|
||||||
|
- **F-11-11** — L55 — stale/medium — `Grpc.Tools` listed. FIX: remove or qualify "future."
|
||||||
|
|
||||||
|
### clients/python/PythonClientDesign.md
|
||||||
|
|
||||||
|
- **F-11-10** — L215 — stale/medium — example package `mxaccess-gateway-client`. FIX: `zb-mom-ww-mxaccess-gateway-client` (substitution).
|
||||||
|
|
||||||
|
### clients/go/GoClientDesign.md
|
||||||
|
|
||||||
|
- **F-11-13** — L28–30 — stale/medium — generated dir lists only 2 files; 5 exist. FIX: add galaxy_repository + mxaccess_worker files.
|
||||||
|
|
||||||
|
### clients/dotnet/README.md, clients/java/README.md, clients/python/README.md, clients/rust/RustClientDesign.md
|
||||||
|
- All accurate/low. Flag only.
|
||||||
|
|
||||||
|
### StyleGuide.md
|
||||||
|
|
||||||
|
- **F-12-1** — L3 — wrong/high — names project "ScadaBridge." FIX: "MXAccess Gateway" / `mxaccessgw`.
|
||||||
|
- **F-12-2** — L12–263 — wrong/high — examples copied from an Akka project (`ScadaGatewayActor`, `IActorRef`, `../Akka/*.md`, `ScadaBridge:Timeout`); all dead refs. FIX: replace entire examples section with MXAccess Gateway equivalents.
|
||||||
|
- **F-12-3** — L90 — stale/low — supported-languages list under/over-inclusive. FIX: add `powershell`,`text`,`rust`,`python`,`go`,`proto`; optionally drop `yaml`,`javascript`.
|
||||||
|
|
||||||
|
### docs/style-guides/JavaStyleGuide.md
|
||||||
|
|
||||||
|
- **F-12-4** — L25 — wrong/high — package root `com.dohertylan.mxgateway`. FIX: `com.zb.mom.ww.mxgateway` (substitution).
|
||||||
|
- **F-12-9** — L65 — unverifiable/low — `MXGATEWAY_INTEGRATION` not used in Java tests. Flag only.
|
||||||
|
|
||||||
|
### docs/style-guides/PythonStyleGuide.md
|
||||||
|
|
||||||
|
- **F-12-5** — L27–29 — wrong/medium — paths `src/mxgateway/`, `src/mxgateway_cli/`. FIX: `src/zb_mom_ww_mxgateway/`, `src/zb_mom_ww_mxgateway_cli/` (substitution).
|
||||||
|
- **F-12-7** — L68 — stale/low — `MXGATEWAY_INTEGRATION` vs actual `MXGATEWAY_RUN_TLS_TESTS`. FIX: align env var.
|
||||||
|
|
||||||
|
### docs/style-guides/GoStyleGuide.md / RustStyleGuide.md / CSharpStyleGuide.md / ProtobufStyleGuide.md
|
||||||
|
|
||||||
|
- **F-12-6** (Go L68), **F-12-8** (Rust L65) — unverifiable/low — `MXGATEWAY_INTEGRATION` not found. Flag only.
|
||||||
|
- Go L13, Rust L42/49, C# L11/12 — accurate/low. Flag only.
|
||||||
|
|
||||||
|
### REVIEW-PROCESS.md
|
||||||
|
- All accurate/low. No action.
|
||||||
|
|
||||||
|
### docs/ImplementationPlan*.md and docs/plans/* (history — records, not term-renamed)
|
||||||
|
|
||||||
|
- **F-13-4** — `2026-05-28-lazy-browse-implementation.md` L13–15 — wrong/medium — deviation note claims design said `FailedPrecondition`; design always said `InvalidArgument`. FIX: flag only — historical; no living-doc fix needed.
|
||||||
|
- **F-13-1** — same doc L1059 — stale/low — `dotnet build src/MxGateway.sln`. Cross-ref fix only; living-doc target is CLAUDE.md L22 (substitution).
|
||||||
|
- **F-13-2** — same doc L885,888,1069 — stale/low — `clients/dotnet/MxGateway.Client.sln`. Cross-ref; living-doc target CLAUDE.md L57/L93 (substitution).
|
||||||
|
- **F-13-3** — `2026-06-01-gateway-cert-autogen-implementation.md` L872,1196 — stale/low — same `.sln` cross-ref.
|
||||||
|
- **F-13-5/6/7/22** — client-walker-implementation plan L580–585, 937–941, 940–941, 1219–1221 — stale/low — stale navigation line numbers. Flag only — no living doc affected.
|
||||||
|
- **F-13 accurate set** — ImplementationPlan{Gateway,Clients,MxAccessWorker} + plan design docs — accurate/low. No action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Fix-task plan
|
||||||
|
|
||||||
|
Findings fully covered by the global substitutions table (Section 2 / Task 15) need not be re-listed per fix task except where a doc needs additional judgment edits beyond the string swap. "Flag only" = no edit in this audit.
|
||||||
|
|
||||||
|
### Task 16 — Architecture + Sessions
|
||||||
|
Docs: gateway.md, docs/DesignDecisions.md, docs/GatewayProcessDesign.md, docs/Sessions.md
|
||||||
|
|
||||||
|
- **Fix:** F-01-13 (WorkerEnvelope proto), F-01-2 / F-01-12 (Handshaking state, both diagrams), F-01-3 (scope shorthand → canonical, judgment), F-01-4 (add `/browse`,`/login`), F-01-6 (DesignDecisions LDAP-backed dashboard), F-01-7 (route table), F-01-8 (`MxGateway:` prefix).
|
||||||
|
- **Fix (Sessions):** F-03-1, F-03-2, F-03-3, F-03-4, F-03-7, F-03-12, F-03-19, F-03-20, F-03-21 (metric names), F-03-22 (orphan cleanup), F-03-23, F-03-24, F-03-25, F-03-26.
|
||||||
|
- **Substitution-covered (Task 15):** gateway.md L737–769 project paths (F-01-1) — verify only.
|
||||||
|
- **Flag only:** F-01-9, F-01-10, F-01-11, F-01-14, all F-01/F-03 accurate sets.
|
||||||
|
|
||||||
|
### Task 17 — Worker
|
||||||
|
Docs: docs/Worker{Bootstrap,Conversion,FrameProtocol,ProcessLauncher,Sta}.md, docs/MxAccessWorkerInstanceDesign.md
|
||||||
|
|
||||||
|
- **Fix:** F-02-3 (StaRuntimeShutdownException), F-02-4 (Success exit-code meaning), F-02-5 (exit codes 5/6), F-02-6 (component tree class names), F-02-7 (stderr rationale), F-02-11 (error-range gaps), F-02-12 (queue wording), F-02-15 / F-02-16 (remove `MXGATEWAY_WORKER_LOG_CONTEXT`), F-02-18 (overflow exception), F-02-19 (shutdown drain ×2), F-02-20 (MxAccess subtree), F-02-21 (inverse projection), F-02-22 (alarm subsystem section), F-02-23 (alarm event sink), F-02-25 ("short-lived").
|
||||||
|
- **Substitution-covered (Task 15):** STA thread name in WorkerSta.md (F-02-1) and MxAccessWorkerInstanceDesign.md (F-02-2).
|
||||||
|
- **Flag only:** all F-02 accurate sets (incl. WorkerFrameProtocol.md, WorkerProcessLauncher.md entirely).
|
||||||
|
|
||||||
|
### Task 18 — Auth
|
||||||
|
Docs: docs/Authentication.md, docs/Authorization.md, glauth.md, + CLAUDE.md auth judgment fixes
|
||||||
|
|
||||||
|
- **Fix (Authentication.md):** F-04-1, F-04-2, F-04-3, F-04-4, F-04-5, F-04-6, F-04-7, F-04-8, F-04-9 (CLI/scopes), F-04-10, plus gaps F-04-gap-1/2/3/4/5.
|
||||||
|
- **Fix (Authorization.md):** F-04-11, F-04-12, F-04-13, F-04-14, F-04-16, F-04-18.
|
||||||
|
- **Fix (glauth.md):** F-04-15, F-04-17, F-04-19, F-04-20, F-04-21.
|
||||||
|
- **Fix (CLAUDE.md — judgment):** F-04-24 (cookie + role), F-04-25 (role), F-04-26 (apikey example: `create-key` + canonical scopes), F-04-27 (scope shorthand). Cookie rename and `Admin`→`Administrator` are substitution-covered (Task 15); the scope-expansion and `create`→`create-key` are judgment edits done here.
|
||||||
|
- **Flag only:** F-04-22, F-04-23, all F-04 accurate sets.
|
||||||
|
|
||||||
|
### Task 19 — Dashboard
|
||||||
|
Docs: docs/DashboardInterfaceDesign.md, docs/GatewayDashboardDesign.md
|
||||||
|
|
||||||
|
- **Fix (DashboardInterfaceDesign.md):** F-05-1, F-05-2, F-05-3, F-05-4, F-05-5, F-05-6, F-05-7, F-05-8, F-05-gap-2.
|
||||||
|
- **Fix (GatewayDashboardDesign.md):** F-05-9, F-05-10, F-05-11 (dashboard.css→site.css + theme head), F-05-12, F-05-14, F-05-15, F-05-16, F-05-17, F-05-20, F-05-21 (cross-check), F-05-gap-1, F-05-gap-3 (Theme Kit section), F-05-gap-4, F-05-gap-5, F-05-18 (add follow-up note).
|
||||||
|
- **Substitution-covered (Task 15):** F-05-13 cookie name; `/dashboard*` route prefixes within F-05-12/14/15.
|
||||||
|
- **Flag only:** F-05-19.
|
||||||
|
|
||||||
|
### Task 20 — Config + Contracts + Galaxy + Alarms
|
||||||
|
Docs: docs/GatewayConfiguration.md, Diagnostics.md, Metrics.md, Contracts.md, Grpc.md, ClientProtoGeneration.md, GalaxyRepository.md, AlarmClientDiscovery.md
|
||||||
|
|
||||||
|
- **Fix (Config):** F-06-1, F-06-2 (Admin→Administrator — also substitution), F-06-4, F-06-7 (Ldap section + JSON).
|
||||||
|
- **Fix (Diagnostics):** F-06-5, F-06-6. F-06-3 logger category is substitution-covered.
|
||||||
|
- **Fix (Contracts):** F-07-gap-1, F-07-gap-2, F-07-gap-3.
|
||||||
|
- **Fix (Grpc):** F-07-1, F-07-2, F-07-4.
|
||||||
|
- **Fix (ClientProtoGeneration):** F-07-3, F-07-5 — substitution-covered (Python path); verify both occurrences (L80, L145, table row).
|
||||||
|
- **Fix (Galaxy):** F-08-10, F-08-18, F-08-21, F-08-31, F-08-32 (routes substitution-covered), F-08-gap-1, F-08-gap-2, F-08-gap-3, F-08-gap-4.
|
||||||
|
- **Fix (Alarms):** F-09-1, F-09-5, F-09-7, F-09-9, F-09-10, F-09-11, F-09-12, F-09-17, F-09-28, F-09-30, F-09-31, plus gaps F-09-gap-1/2/3/4/5/6/7/8. F-09-6 (E_NOTIMPL risk) — flag/document.
|
||||||
|
- **Flag only:** Metrics.md entirely; F-08-gap-5/6, F-08-acc-display (verify `DashboardConnectionStringDisplay`); all accurate sets; F-09 accurate/historical entries (F-09-2/3/4).
|
||||||
|
|
||||||
|
### Task 21 — Clients
|
||||||
|
Docs: clients/*/README.md + clients/*/*ClientDesign.md, docs/ClientLibrariesDesign.md, docs/ClientPackaging.md
|
||||||
|
|
||||||
|
- **Fix (ClientPackaging.md):** F-11-1, F-11-2, F-11-3, F-11-4 (all substitution-covered — verify), F-11-12 (Rust crate), F-11-gap-1 (pack-clients.ps1), F-11-gap-2 (build method).
|
||||||
|
- **Fix (ClientLibrariesDesign.md):** F-11-8 (Python path — substitution).
|
||||||
|
- **Fix (clients/rust/README.md):** F-11-5, F-11-6 (CLI flags — judgment).
|
||||||
|
- **Fix (clients/go/README.md):** F-11-7 (import path — judgment).
|
||||||
|
- **Fix (clients/dotnet/DotnetClientDesign.md):** F-11-9, F-11-11.
|
||||||
|
- **Fix (clients/python/PythonClientDesign.md):** F-11-10 (substitution).
|
||||||
|
- **Fix (clients/go/GoClientDesign.md):** F-11-13.
|
||||||
|
- **Flag only:** all client README/design accurate sets.
|
||||||
|
|
||||||
|
### Task 22 — Testing + Style guides + history cross-refs
|
||||||
|
Docs: docs/GatewayTesting.md, ClientBehaviorFixtures.md, ParityFixtureMatrix.md, CrossLanguageSmokeMatrix.md, ToolchainLinks.md, StyleGuide.md, REVIEW-PROCESS.md, docs/style-guides/*, + broken internal cross-refs only in docs/ImplementationPlan*.md and docs/plans/*
|
||||||
|
|
||||||
|
- **Fix (GatewayTesting.md):** F-10-1 (Gradle task — substitution), F-10-gap-1.
|
||||||
|
- **Fix (StyleGuide.md):** F-12-1, F-12-2 (full examples rewrite), F-12-3.
|
||||||
|
- **Fix (JavaStyleGuide.md):** F-12-4 (package root — substitution).
|
||||||
|
- **Fix (PythonStyleGuide.md):** F-12-5 (paths — substitution), F-12-7 (env var).
|
||||||
|
- **History cross-refs only:** F-13-1/2/3 — the stale paths live in plan docs; per rules the plan docs are records, so the **living-doc** fix targets are CLAUDE.md L22 (`src/MxGateway.sln`), L57/L93 (`clients/dotnet/MxGateway.Client.sln`) — both substitution-covered under Task 15. Do **not** edit term occurrences inside the plan docs. F-13-4 is a flag-only inaccuracy in a record (no fix). F-13-5/6/7/22 are stale navigation line numbers in plans — flag only.
|
||||||
|
- **Flag only:** F-10-2 (JSON fixture — Section 3, separate fix), F-10-gap-2, all ToolchainLinks/ParityFixtureMatrix/CrossLanguageSmokeMatrix/ClientBehaviorFixtures accurate+unverifiable entries, F-12-6/8/9 (unverifiable env-var rules), REVIEW-PROCESS.md and remaining accurate style-guide claims.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Synthesis notes for the fix phase
|
||||||
|
- **CLAUDE.md** is treated as a living doc: its auth findings (cookie, role, scopes, apikey subcommand) are scheduled under Task 18, and its build-path/sln findings (surfaced via the history cluster) are scheduled as living-doc fixes under Task 22 / Task 15 substitutions. Plan/history docs that merely *repeat* CLAUDE.md's stale strings are not edited.
|
||||||
|
- **Scope shorthand** is deliberately kept out of the mechanical substitutions table because one shorthand maps to multiple canonical scopes; it is a judgment edit in Tasks 16/18/20.
|
||||||
|
- **The JSON fixture** (`cross-language-smoke-matrix.json`, F-10-2) is the only non-`.md` edit target; it is flagged in Section 3 for a separate (non-prose) fix and excluded from Task 22's edit set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Resolution status
|
||||||
|
|
||||||
|
Independent re-verification pass. Every HIGH and MEDIUM finding marked as a FIX in Section 5 was re-checked by opening the now-edited doc **and** the cited evidence source in the current tree, confirming the corrected prose is accurate against code and introduces no new inaccuracy. Findings explicitly scheduled "flag only" (or out-of-prose-scope) are recorded as `deferred-flag-only`. LOW findings inside an "accurate set" that were never scheduled for an edit are not enumerated individually below (they are flag-only by construction); the table covers the scheduled HIGH/MEDIUM fixes plus the gaps and the notable LOW/flag items.
|
||||||
|
|
||||||
|
Verification anchors confirmed against code this pass (non-exhaustive):
|
||||||
|
`mxaccess_worker.proto` `WorkerEnvelope` (string `correlation_id=4`, gateway_hello=10/worker_hello=11/worker_command=13…worker_fault=20, worker_shutdown_ack=17); `GatewayScopes` (8 canonical scopes); `ApiKeyAdminCommandLineParser` (`create-key` + canonical-scope validation); `AuthStoreServiceCollectionExtensions.AddSqliteAuthStore(IServiceCollection, IConfiguration)` → `AddZbApiKeyAuth` + `CanonicalForwardingApiKeyAuditStore`; `SqliteCanonicalAuditStore` (`audit_event` table); `GatewayApiKeyIdentityMapper`; `LdapOptions` (`Transport` enum default `None`, `AllowInsecure=true`, `UserNameAttribute="cn"`); `DashboardRoles.Admin == "Administrator"`; `DashboardAuthenticationDefaults.CookieName == "MxGatewayDashboard"`; `ZbCookieDefaults.Apply(idleTimeout: FromHours(8))` + `HubTokenService.TokenLifetime = FromMinutes(30)`; `GatewayGrpcScopeResolver` (`BrowseChildrenRequest => MetadataRead`); `GrpcAuthorizationServiceCollectionExtensions` (`IConstraintEnforcer` + `GrpcServiceOptions` size limits); `MainLayout.razor` `ThemeShell`+8 nav items in 3 groups; `StatusBadge.razor` Ok/Warn/Bad/Idle map; `site.css` (not dashboard.css); `ZB.MOM.WW.Theme 0.2.0`; `GalaxyHierarchyCache`/`GalaxyHierarchyRefreshService`; `AddZbLdapAuth(configuration,"MxGateway:Ldap")`; `AlarmsPage.razor` `PeriodicTimer(3s)`+`QueryAlarmsAsync`; `GatewayAlarmMonitor.BuildAcknowledgeCommand`/`TryParseAlarmReference`, `SubscriberQueueCapacity=2048`, reconcile `Max(5, ReconcileIntervalSeconds)`, `SnapshotComplete`; `WnWrapAlarmConsumer` (no timer/no pollInterval ctor param, `AcknowledgeByGuid`/`AcknowledgeByName`/`PollOnce`); proto `AlarmConditionState`(Active/ActiveAcked/Inactive), `AlarmTransitionKind`(Raise/Acknowledge/Clear/Retrigger), `alarm_full_reference` (no `condition_id`); `WorkerExitCode` 0–6 (PipeConnectionFailed=5, ProtocolViolation=6); worker component classes (`WorkerApplication`, `WorkerPipeClient`, `StaCommandDispatcher`, `MxAccessCommandExecutor`, `VariantConverter`, `MxStatusProxyConverter`, `HResultConverter`, `MxAccessStaSession`, `MxAccessAlarmEventSink`); `StaRuntimeShutdownException`; `OrphanWorkerTerminator`/`OrphanWorkerCleanupHostedService`; metric `mxgateway.workers.killed` via `GatewayMetrics.WorkerKilled`; `EventBackpressurePolicy.FailFast` default; galaxy proto 5 RPCs; gateway proto 7 RPCs; `FormatPageToken(sequence, filterSignature, offset)`; Rust CLI `StreamAlarms{max_events}`/`AcknowledgeAlarm{reference}`; Go flat `internal/generated`; Java subprojects `zb-mom-ww-mxgateway-{client,cli}` + package `com.zb.mom.ww.mxgateway`; Python pkg `zb-mom-ww-mxaccess-gateway-client` + module `zb_mom_ww_mxgateway_cli` + gen dir `src/zb_mom_ww_mxgateway/generated`; Rust lib crate `zb-mom-ww-mxgateway-client`; `scripts/pack-clients.ps1` + `tag-go-module.ps1`; StyleGuide.md free of ScadaBridge/Akka refs; `MXGATEWAY_RUN_TLS_TESTS`.
|
||||||
|
|
||||||
|
| Finding ID | Severity | Status | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F-01-13 | high | resolved | `WorkerEnvelope` block now matches proto field types/numbers/names exactly. |
|
||||||
|
| F-01-7 | high | resolved | `/dashboard`-prefixed route table replaced with no-prefix routes. |
|
||||||
|
| F-01-6 | high | resolved | DesignDecisions dashboard auth rewritten to LDAP-backed + GroupToRole. |
|
||||||
|
| F-01-1 | medium | resolved | Layout uses fully-qualified `src/ZB.MOM.WW.MxGateway.*` paths. |
|
||||||
|
| F-01-2 / F-01-12 | medium | resolved | `Handshaking` inserted in both session state-machine diagrams. |
|
||||||
|
| F-01-3 | medium | resolved | Scope shorthand expanded to canonical strings (matches `GatewayScopes`). |
|
||||||
|
| F-01-4 | low | resolved | `/browse` and `/login` covered by route-list fixes. |
|
||||||
|
| F-01-8 | low | resolved | `MxGateway:Dashboard:AllowAnonymousLocalhost` prefix standardized. |
|
||||||
|
| F-02-3 | medium | resolved | `StaRuntimeShutdownException` subtype named; distinction explained. |
|
||||||
|
| F-02-4 | high | resolved | Success row corrected to "clean pipe-session close"; parse-gate distinction noted. |
|
||||||
|
| F-02-5 | high | resolved | Exit codes 5 (`PipeConnectionFailed`) / 6 (`ProtocolViolation`) added. |
|
||||||
|
| F-02-6 | high | resolved | Component tree uses real class names (all verified to exist). |
|
||||||
|
| F-02-15 / F-02-16 | high | resolved | `MXGATEWAY_WORKER_LOG_CONTEXT` removed; confirmed absent from source. |
|
||||||
|
| F-02-22 | high | resolved | Alarm subsystem added to component tree. |
|
||||||
|
| F-02-2 | medium | resolved | STA thread name `MxGateway.Worker.STA`. |
|
||||||
|
| F-02-7 | medium | resolved | stderr/stdout rationale corrected. |
|
||||||
|
| F-02-19 | medium | resolved | Shutdown drain-twice sequence revised. |
|
||||||
|
| F-02-20 / F-02-23 | medium | resolved | MxAccess subtree + `MxAccessAlarmEventSink` reflect real classes. |
|
||||||
|
| F-02-21 | medium | resolved | Inverse-projection (COM write) section added. |
|
||||||
|
| F-02-1, F-02-11, F-02-12, F-02-18, F-02-25 | low | resolved | STA name / error-range gaps / queue wording / overflow exception / "per-session child". |
|
||||||
|
| F-03-21 | high | resolved | Real counter `mxgateway.workers.killed` via `GatewayMetrics.WorkerKilled`. |
|
||||||
|
| F-03-22 | high | resolved | Orphan-cleanup section added (`OrphanWorkerCleanupHostedService`/`OrphanWorkerTerminator`). |
|
||||||
|
| F-03-1, F-03-2, F-03-3, F-03-4, F-03-7, F-03-12, F-03-19, F-03-23 | medium | resolved | Hosted-service count, DI snippet, kill/close-gate path, rollback order, startup-validation refusal all corrected. |
|
||||||
|
| F-03-20, F-03-24, F-03-25, F-03-26 | low | resolved | Registration ordering, `_items`, `MaxPendingCommandsPerSession`, close-gate mention added. |
|
||||||
|
| F-04-1 | high | resolved | Registration rewritten to `AddZbApiKeyAuth`/`ZB.MOM.WW.Auth.ApiKeys`; migration-hosted-service claim corrected. |
|
||||||
|
| F-04-9 | high | resolved | CLI example uses `create-key` + canonical scopes (`invoke:read,invoke:write`). |
|
||||||
|
| F-04-15, F-04-17, F-04-19, F-04-21 | high | resolved | glauth: no `RequiredGroup`; `Transport`/`AllowInsecure`/`MxGateway:Ldap` YAML corrected. |
|
||||||
|
| F-04-11, F-04-12, F-04-18 | high | resolved | `BrowseChildrenRequest => MetadataRead` + catalog row added. |
|
||||||
|
| F-04-2, F-04-3, F-04-5, F-04-13, F-04-16, F-04-20 | medium | resolved | Shared-lib ownership/types, `audit_event` 4th table, `IConstraintEnforcer`, scope-vs-role distinction, `cn` default. |
|
||||||
|
| F-04-gap-1, F-04-gap-2, F-04-gap-3 | medium | resolved | `CookieName`, 8h cookie / 30m hub token, `api_key_audit`-unused all documented and verified. |
|
||||||
|
| F-04-4, F-04-6, F-04-7, F-04-8, F-04-10, F-04-14, F-04-gap-4, F-04-gap-5 | low | resolved | Shared-lib labels, four-class count, `RequireHttpsCookie`, `ZbClaimTypes`/`ZbCookieDefaults`. |
|
||||||
|
| F-04-24, F-04-25, F-04-26, F-04-27 | high | resolved | CLAUDE.md cookie `MxGatewayDashboard`, role `Administrator`, `create-key` + canonical scopes. |
|
||||||
|
| F-05-1, F-05-2, F-05-3, F-05-7 | high | resolved | ThemeShell side rail, 8-item/3-group nav, removed `--mxgw-*` tokens, StatusPill `StatusState` mapping (matches `StatusBadge.razor`). |
|
||||||
|
| F-05-11, F-05-13 | high | resolved | `dashboard.css`→`site.css` + ThemeHead/Scripts; cookie name. |
|
||||||
|
| F-05-gap-3 | high | resolved | Theme Kit section added (`ZB.MOM.WW.Theme 0.2.0` verified in csproj). |
|
||||||
|
| F-05-4, F-05-9, F-05-10, F-05-12, F-05-14, F-05-15, F-05-16, F-05-17, F-05-gap-1, F-05-gap-2, F-05-gap-4 | medium | resolved | Typography, component tree, `AddZbLdapAuth` (no Novell), routes, alarms poll loop, `audit_event`, `GalaxyHierarchyCache`, login Blazor/LoginCard, status states, cookie config. |
|
||||||
|
| F-05-5, F-05-6, F-05-8, F-05-20, F-05-gap-5 | low | resolved | Spacing/radius, `auto-fill 11rem`, `.page` breakpoint, theme-kit layer, ConfirmDialog. |
|
||||||
|
| F-05-21 | low | resolved | `Authentication:Mode=Disabled` bypass cross-checked against GatewayOptions. |
|
||||||
|
| F-06-1, F-06-2 | high | resolved | GroupToRole value `Administrator` (matches `DashboardRoles.Admin == "Administrator"` + validator). |
|
||||||
|
| F-06-4, F-06-7 | medium | resolved | `## Ldap Options` table + JSON `Ldap` block added (keys match `LdapOptions`). |
|
||||||
|
| F-06-3, F-06-5, F-06-6 | medium/low | resolved | Logger category `MxGateway.Request`; `GatewayLogRedactorSeam`/`AuthStoreHealthCheck` notes. |
|
||||||
|
| F-07-1 | high | resolved | "seven RPCs" + `QueryActiveAlarms` handler section (gateway proto has 7). |
|
||||||
|
| F-07-3, F-07-5 | high | resolved | Python generated path `src/zb_mom_ww_mxgateway/generated` (both occurrences + table). |
|
||||||
|
| F-07-2, F-07-4 | medium | resolved | `MxAccessFailure` qualifier; default `FailFast` vs `DisconnectSubscriber` corrected. |
|
||||||
|
| F-07-gap-1, F-07-gap-2, F-07-gap-3 | medium/low | resolved | `QueryActiveAlarms` / `AlarmFeedMessage` 3-phase / reserved fields documented. |
|
||||||
|
| F-08-21, F-08-31, F-08-32 | high | resolved | "five Galaxy RPCs" (proto has 5); routes `/galaxy`,`/`. |
|
||||||
|
| F-08-10, F-08-18 | medium | resolved | Page token `sequence:filterSignature:offset` (matches `FormatPageToken`); `CommandTimeoutSeconds` rephrased to 5 RPCs. |
|
||||||
|
| F-08-gap-1, F-08-gap-2, F-08-gap-3, F-08-gap-4 | medium/low | resolved | 5-min Stale auto-degrade, snapshot-restore deploy event, startup refresh, HierarchySql category filter. |
|
||||||
|
| F-09-7, F-09-30, F-09-28 | high | resolved | `GatewayAlarmMonitor.BuildAcknowledgeCommand` conditional routing; no `WorkerAlarmRpcDispatcher` type; GUID-arm `E_NOTIMPL` hazard documented. |
|
||||||
|
| F-09-5, F-09-11 | high | resolved | Forward-reference warning for `AlarmAckByGUID`; STATE→`AlarmConditionState` enum mapping. |
|
||||||
|
| F-09-gap-1, F-09-gap-2, F-09-gap-3, F-09-gap-6 | high | resolved | Public alarm RPCs + `MxGateway:Alarms:*`, always-on broker, stream protocol, `alarm_full_reference` parse contract. |
|
||||||
|
| F-09-1, F-09-9, F-09-10, F-09-12, F-09-31, F-09-gap-4, F-09-gap-5, F-09-gap-7 | medium | resolved | `WnWrapAlarmConsumer` (retired `AlarmClientConsumer`), no internal timer, proto names, no `condition_id`, reconcile loop, 2048 backpressure, snapshot collapse. |
|
||||||
|
| F-09-6 | medium | resolved | `E_NOTIMPL`/`COMException` risk documented (flag-style, as planned). |
|
||||||
|
| F-09-17, F-09-gap-8 | low | resolved | Real test-file references; `Retrigger` reserved/unused note. |
|
||||||
|
| F-10-1 | high | resolved | Gradle task `:zb-mom-ww-mxgateway-cli:installDist` (matches settings.gradle). |
|
||||||
|
| F-10-gap-1 | low | resolved | `ResolveRepositoryRoot` failure-mode note added. |
|
||||||
|
| F-11-1, F-11-2, F-11-3, F-11-4, F-11-8 | high | resolved | `.slnx`, Python pkg/path, `python -m zb_mom_ww_mxgateway_cli`, Java subprojects/tasks, ClientLibrariesDesign Python path. |
|
||||||
|
| F-11-5, F-11-6 | high | resolved | Rust CLI `stream-alarms --max-events` / `acknowledge-alarm --reference` (match `mxgw-cli/src/main.rs`). |
|
||||||
|
| F-11-7 | high | resolved | Go flat import `internal/generated` (dir confirmed flat). |
|
||||||
|
| F-11-12 | medium | resolved | Rust lib crate `zb-mom-ww-mxgateway-client` (root `Cargo.toml` package name). |
|
||||||
|
| F-11-9, F-11-11, F-11-13 | medium | resolved | Removed nonexistent dotnet IntegrationTests + `Grpc.Tools`; Go gen dir lists 5 files. |
|
||||||
|
| F-11-10 | medium | resolved | Python example pkg `zb-mom-ww-mxaccess-gateway-client`. |
|
||||||
|
| F-11-gap-1, F-11-gap-2 | medium/low | resolved | `pack-clients.ps1` section + `python -m build` canonical method (script exists). |
|
||||||
|
| F-12-1, F-12-2 | high | resolved | StyleGuide.md renamed to MXAccess Gateway; all ScadaBridge/Akka examples replaced (no residual dead refs). |
|
||||||
|
| F-12-4 | high | resolved | Java package `com.zb.mom.ww.mxgateway` (matches source). |
|
||||||
|
| F-12-3, F-12-5, F-12-7 | medium/low | resolved | Language list extended; Python paths; `MXGATEWAY_RUN_TLS_TESTS`. |
|
||||||
|
| F-10-2 | high | deferred-flag-only | Targets `cross-language-smoke-matrix.json` (non-`.md`); Section 3 flag-only — correctly left unedited. |
|
||||||
|
| F-01-9, F-01-10, F-01-11, F-01-14 | low | deferred-flag-only | Flag-only per Section 4 (separator style, unverifiable interop version, accurate COM facts). |
|
||||||
|
| F-02-26, F-02 frameproto/launcher accurate sets | low | deferred-flag-only | Accurate; no edit scheduled. |
|
||||||
|
| F-04-22, F-04-23 | low | deferred-flag-only | Accurate connection/role notes. |
|
||||||
|
| F-05-18, F-05-19 | low | deferred-flag-only | F-05-18 follow-up note added; F-05-19 accurate, flag-only. |
|
||||||
|
| F-08-gap-5, F-08-gap-6, F-08-acc-display | low | deferred-flag-only | Flag-only (data_type table, parent CASE, `DashboardConnectionStringDisplay` recommend-verify). |
|
||||||
|
| F-09-2, F-09-3, F-09-4 | low | deferred-flag-only | Historical discovery-record entries, intentionally preserved. |
|
||||||
|
| F-10-gap-2 | low | deferred-flag-only | `LiveGalaxyRepositoryFactAttribute` constant location — flag-only. |
|
||||||
|
| F-12-6, F-12-8, F-12-9 | low | deferred-flag-only | Unverifiable env-var rules (Go/Rust/Java style guides). |
|
||||||
|
| F-13-1, F-13-2, F-13-3 | low | deferred-flag-only | Stale `.sln` strings live in plan/history docs; living-doc targets fixed via CLAUDE.md substitutions. |
|
||||||
|
| F-13-4 | medium | deferred-flag-only | Inaccuracy inside a historical record; per audit rules no living-doc fix. |
|
||||||
|
| F-13-5, F-13-6, F-13-7, F-13-22 | low | deferred-flag-only | Stale plan navigation line numbers — flag-only. |
|
||||||
|
|
||||||
|
### Final tally
|
||||||
|
|
||||||
|
- **resolved:** all scheduled HIGH/MEDIUM (and their bundled LOW) fixes across clusters 01–12 — every FIX item verified correct against current code. Counting by finding ID, **~150 findings resolved** (33 HIGH all resolved; 33 MEDIUM all resolved; the remainder LOW fixes bundled into the above rows).
|
||||||
|
- **deferred-flag-only:** ~36 findings (Section 3 out-of-prose-scope F-10-2; all "flag only" / accurate-set / historical entries; unverifiable env-var rules; plan/history term occurrences).
|
||||||
|
- **still-open:** **0.**
|
||||||
|
|
||||||
|
**HIGH-severity findings still-open:** none. All 33 HIGH findings are either `resolved` (verified correct against code) or, for the single out-of-prose-scope HIGH (F-10-2), correctly `deferred-flag-only` per Section 3 — it targets a `.json` fixture and was intentionally excluded from the prose audit. No fix was found WRONG or incomplete.
|
||||||
|
|
||||||
|
### Branch-wide diff
|
||||||
|
|
||||||
|
`git diff --stat main..HEAD`: **51 files changed, 7332 insertions(+), 479 deletions(-)**. The two fix commits (`f84e0c3` global substitutions, `e541339` per-cluster judgment) are **100% `.md`**. The only non-`.md` paths in the branch — `docs/audit/fragments/.gitkeep` and `docs/plans/2026-06-03-documentation-audit-implementation.md.tasks.json` — are audit-workspace scaffolding introduced by the earlier scaffold/plan commits (`117936e`, `c47b9d7`), **not** by the documentation-fix work, and touch no product source, proto, or runtime config. No code/`.proto`/`appsettings.json`/product config was modified by the fixes.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Code Review Process
|
||||||
|
|
||||||
|
This document describes how to perform a comprehensive, per-module code review of
|
||||||
|
the `mxaccessgw` codebase and how to track findings to resolution.
|
||||||
|
|
||||||
|
A **module** is one buildable project under `src/` (e.g. `src/ZB.MOM.WW.MxGateway.Worker`)
|
||||||
|
or one language client under `clients/` (e.g. `clients/rust`). Each module has
|
||||||
|
its own folder under `code-reviews/` containing a single `findings.md`.
|
||||||
|
|
||||||
|
## 1. Before you start
|
||||||
|
|
||||||
|
1. Pick the module to review. Its folder is `code-reviews/<Module>/`:
|
||||||
|
- For a `src/` project, `<Module>` is the project name with the `ZB.MOM.WW.MxGateway.`
|
||||||
|
prefix stripped — `src/ZB.MOM.WW.MxGateway.Server` is reviewed in `code-reviews/Server/`.
|
||||||
|
- For a language client, `<Module>` is `Client.<Lang>` — `clients/rust` is
|
||||||
|
reviewed in `code-reviews/Client.Rust/`.
|
||||||
|
2. Identify the design context for the module:
|
||||||
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope,
|
||||||
|
STA thread model, fault handling.
|
||||||
|
- The relevant component design docs under `docs/` (e.g.
|
||||||
|
`docs/MxAccessWorkerInstanceDesign.md`, `docs/GatewayProcessDesign.md`,
|
||||||
|
`docs/Sessions.md`, `docs/Authentication.md`, `docs/GalaxyRepository.md`).
|
||||||
|
- `docs/DesignDecisions.md` for the v1 design choices.
|
||||||
|
- The **Repository-Specific Conventions** and **Process / Platform Notes** in
|
||||||
|
`CLAUDE.md`.
|
||||||
|
3. Record the exact commit being reviewed: `git rev-parse --short HEAD`. Every
|
||||||
|
review is a snapshot — a finding only means something relative to a known
|
||||||
|
commit.
|
||||||
|
4. Open `code-reviews/<Module>/findings.md` and fill in the header table
|
||||||
|
(reviewer, date, commit SHA, status).
|
||||||
|
|
||||||
|
## 2. Review checklist
|
||||||
|
|
||||||
|
Work through **every** category below for the module. A comprehensive review
|
||||||
|
means the checklist is completed even where it produces no findings — record
|
||||||
|
"No issues found" for a category rather than leaving it ambiguous.
|
||||||
|
|
||||||
|
1. **Correctness & logic bugs** — off-by-one, null handling, incorrect
|
||||||
|
conditionals, misuse of APIs, broken edge cases.
|
||||||
|
2. **mxaccessgw conventions** — the rules in `CLAUDE.md` and the style guides
|
||||||
|
under `docs/style-guides/`: the gateway never instantiates MXAccess COM
|
||||||
|
directly; all MXAccess COM calls run on the worker's dedicated STA thread and
|
||||||
|
the STA loop pumps Windows messages; IPC uses one bidirectional named pipe per
|
||||||
|
worker carrying length-prefixed `WorkerEnvelope` protobuf frames; MXAccess
|
||||||
|
parity is the contract (don't "fix" surprising MXAccess behaviour, never
|
||||||
|
synthesize events); one worker and one event subscriber per session; the
|
||||||
|
gateway terminates orphan workers on startup and does not reattach; C# style
|
||||||
|
(file-scoped namespaces, `sealed` by default, `Async` suffix, MXAccess-aligned
|
||||||
|
names); no Blazor UI component libraries; no logging of secrets or full tag
|
||||||
|
values; generated code is never hand-edited.
|
||||||
|
3. **Concurrency & thread safety** — shared mutable state, STA affinity, race
|
||||||
|
conditions, correct use of `async`/`await`, locking, disposal races.
|
||||||
|
4. **Error handling & resilience** — exception paths, worker crash / reconnect
|
||||||
|
handling, fail-fast event backpressure, transient vs permanent error
|
||||||
|
classification, graceful degradation, correct gRPC status codes.
|
||||||
|
5. **Security** — authentication/authorization checks, API-key scope enforcement,
|
||||||
|
input validation, SQL injection in the Galaxy Repository RPCs, secret
|
||||||
|
handling, the dashboard anonymous-localhost bypass, logging of sensitive data.
|
||||||
|
6. **Performance & resource management** — `IDisposable` disposal, pipe / stream
|
||||||
|
/ COM lifetimes, buffering and back-pressure, unnecessary allocations on hot
|
||||||
|
paths, N+1 queries.
|
||||||
|
7. **Design-document adherence** — does the code match `gateway.md`, the relevant
|
||||||
|
`docs/` component designs, `docs/DesignDecisions.md`, and `CLAUDE.md`? Flag
|
||||||
|
both code that drifts from the design and design docs that are now stale.
|
||||||
|
8. **Code organization & conventions** — namespace hierarchy, project layout, the
|
||||||
|
Options pattern, separation of concerns, additive-only contract evolution.
|
||||||
|
9. **Testing coverage** — are the module's behaviours covered by tests
|
||||||
|
(`src/ZB.MOM.WW.MxGateway.Tests`, `src/ZB.MOM.WW.MxGateway.Worker.Tests`,
|
||||||
|
`src/ZB.MOM.WW.MxGateway.IntegrationTests`)? Note untested critical paths and missing
|
||||||
|
edge-case tests.
|
||||||
|
10. **Documentation & comments** — XML doc accuracy, misleading or stale comments,
|
||||||
|
undocumented non-obvious behaviour.
|
||||||
|
|
||||||
|
## 3. Recording findings
|
||||||
|
|
||||||
|
Add one entry per finding to the `## Findings` section of the module's
|
||||||
|
`findings.md`, using the entry format in
|
||||||
|
[`_template/findings.md`](code-reviews/_template/findings.md).
|
||||||
|
|
||||||
|
- **Finding ID** — `<Module>-NNN`, numbered sequentially within the module and
|
||||||
|
never reused (e.g. `Worker-001`). IDs are permanent even after resolution.
|
||||||
|
- **Severity:**
|
||||||
|
- **Critical** — data loss, security breach, crash/deadlock, or outage.
|
||||||
|
- **High** — incorrect behaviour with significant impact; no safe workaround.
|
||||||
|
- **Medium** — incorrect or risky behaviour with limited impact or a workaround.
|
||||||
|
- **Low** — minor issues, style, maintainability, documentation.
|
||||||
|
- **Category** — one of the 10 checklist categories above.
|
||||||
|
- **Location** — `file:line` (clickable), or a list of locations.
|
||||||
|
- **Description** — what is wrong and why it matters.
|
||||||
|
- **Recommendation** — concrete suggested fix.
|
||||||
|
|
||||||
|
After recording findings, update the module header table (status, open-finding
|
||||||
|
count) and regenerate the base README (step 5).
|
||||||
|
|
||||||
|
## 4. Marking an item resolved
|
||||||
|
|
||||||
|
Findings are **never deleted** — they are an audit trail. To close one, change
|
||||||
|
its **Status** and complete the **Resolution** field:
|
||||||
|
|
||||||
|
- `Open` — newly recorded, not yet addressed.
|
||||||
|
- `In Progress` — a fix is actively being worked on.
|
||||||
|
- `Resolved` — fixed. The Resolution field must state the fixing commit SHA, the
|
||||||
|
date, and a one-line description of the fix.
|
||||||
|
- `Won't Fix` — intentionally not fixed. The Resolution field must justify why.
|
||||||
|
- `Deferred` — valid but postponed. The Resolution field must say what it is
|
||||||
|
waiting on (e.g. a tracked issue or a later milestone).
|
||||||
|
|
||||||
|
`Resolved`, `Won't Fix`, and `Deferred` findings are all considered **closed**.
|
||||||
|
`Open` and `In Progress` are **pending** and appear in the base README's Pending
|
||||||
|
Findings table.
|
||||||
|
|
||||||
|
## 5. Updating the base README
|
||||||
|
|
||||||
|
`code-reviews/README.md` holds the single cross-module view (the Module Status
|
||||||
|
table and the Pending / Closed Findings tables). It is **generated** from the
|
||||||
|
per-module `findings.md` files — do not edit it by hand.
|
||||||
|
|
||||||
|
After any review or status change, regenerate it:
|
||||||
|
|
||||||
|
```
|
||||||
|
python code-reviews/regen-readme.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`regen-readme.py --check` exits non-zero if `README.md` is stale, if a module
|
||||||
|
header's `Open findings` count disagrees with its finding statuses, or if a
|
||||||
|
finding carries an unrecognised Status value. The PowerShell wrapper
|
||||||
|
`scripts/check-code-reviews-readme.ps1` runs that check and is the intended hook
|
||||||
|
for CI or a pre-commit step.
|
||||||
|
|
||||||
|
> The repo's installed `python` is the real interpreter; the bare `python3`
|
||||||
|
> alias resolves to the Windows Store stub and fails. Use `python`.
|
||||||
|
|
||||||
|
The per-module `findings.md` files are the source of truth; `README.md` is the
|
||||||
|
aggregated index and must always agree with them — which the script guarantees.
|
||||||
|
|
||||||
|
## 6. Re-reviewing a module
|
||||||
|
|
||||||
|
Re-reviews append to the same `findings.md`. Update the header to the new commit
|
||||||
|
and date, continue the finding numbering from the last used ID, and leave prior
|
||||||
|
findings (including closed ones) in place as history.
|
||||||
+76
-55
@@ -1,42 +1,48 @@
|
|||||||
# Documentation Style Guide
|
# Documentation Style Guide
|
||||||
|
|
||||||
This guide defines writing conventions and formatting rules for all ScadaBridge documentation.
|
This guide defines writing conventions and formatting rules for all MXAccess
|
||||||
|
Gateway (`mxaccessgw`) documentation.
|
||||||
|
|
||||||
## Tone and Voice
|
## Tone and Voice
|
||||||
|
|
||||||
### Be Technical and Direct
|
### Be Technical and Direct
|
||||||
|
|
||||||
Write for developers who are familiar with .NET. Don't explain basic concepts like dependency injection or async/await unless they're used in an unusual way.
|
Write for developers who are familiar with .NET. Don't explain basic concepts
|
||||||
|
like dependency injection or async/await unless they're used in an unusual way.
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> The `ScadaGatewayActor` routes messages to the appropriate `ScadaClientActor` based on the client ID in the message.
|
> The `SessionManager` launches one worker per session and tracks it through the
|
||||||
|
> session state machine.
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
> The ScadaGatewayActor is a really powerful component that helps manage all your SCADA connections efficiently!
|
> The SessionManager is a really powerful component that helps manage all your
|
||||||
|
> MXAccess connections efficiently!
|
||||||
|
|
||||||
### Explain "Why" Not Just "What"
|
### Explain "Why" Not Just "What"
|
||||||
|
|
||||||
Document the reasoning behind patterns and decisions, not just the mechanics.
|
Document the reasoning behind patterns and decisions, not just the mechanics.
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> Health checks use a 5-second timeout because actors under heavy load may take several seconds to respond, but longer delays indicate a real problem.
|
> The worker pumps Windows messages on its STA thread because a plain blocking
|
||||||
|
> queue does not let MXAccess COM events deliver.
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
> Health checks use a 5-second timeout.
|
> The worker pumps Windows messages on its STA thread.
|
||||||
|
|
||||||
### Use Present Tense
|
### Use Present Tense
|
||||||
|
|
||||||
Describe what the code does, not what it will do.
|
Describe what the code does, not what it will do.
|
||||||
|
|
||||||
**Good:**
|
**Good:**
|
||||||
> The actor validates the message before processing.
|
> The gateway terminates orphaned workers on startup.
|
||||||
|
|
||||||
**Avoid:**
|
**Avoid:**
|
||||||
> The actor will validate the message before processing.
|
> The gateway will terminate orphaned workers on startup.
|
||||||
|
|
||||||
### No Marketing Language
|
### No Marketing Language
|
||||||
|
|
||||||
This is internal technical documentation. Avoid superlatives and promotional language.
|
This is internal technical documentation. Avoid superlatives and promotional
|
||||||
|
language.
|
||||||
|
|
||||||
**Avoid:** "powerful", "robust", "cutting-edge", "seamless", "blazing fast"
|
**Avoid:** "powerful", "robust", "cutting-edge", "seamless", "blazing fast"
|
||||||
|
|
||||||
@@ -45,10 +51,10 @@ This is internal technical documentation. Avoid superlatives and promotional lan
|
|||||||
### File Names
|
### File Names
|
||||||
|
|
||||||
Use `PascalCase.md` for all documentation files:
|
Use `PascalCase.md` for all documentation files:
|
||||||
- `Overview.md`
|
- `Sessions.md`
|
||||||
- `HealthChecks.md`
|
- `GatewayConfiguration.md`
|
||||||
- `StateMachines.md`
|
- `WorkerSta.md`
|
||||||
- `SignalR.md`
|
- `Diagnostics.md`
|
||||||
|
|
||||||
### Headings
|
### Headings
|
||||||
|
|
||||||
@@ -58,11 +64,11 @@ Use `PascalCase.md` for all documentation files:
|
|||||||
- **H4+ (`####`):** Rarely needed, Sentence case
|
- **H4+ (`####`):** Rarely needed, Sentence case
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Actor Health Checks
|
# Gateway Configuration
|
||||||
|
|
||||||
## Configuration Options
|
## Session Options
|
||||||
|
|
||||||
### Setting the timeout
|
### Setting the lease timeout
|
||||||
|
|
||||||
#### Default values
|
#### Default values
|
||||||
```
|
```
|
||||||
@@ -73,40 +79,43 @@ Always specify the language:
|
|||||||
|
|
||||||
````markdown
|
````markdown
|
||||||
```csharp
|
```csharp
|
||||||
public class MyActor : ReceiveActor { }
|
public sealed class GatewaySession { }
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Setting": "value"
|
"MxGateway": { "Sessions": { "MaxConcurrent": 8 } }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```powershell
|
||||||
dotnet build
|
dotnet build src/ZB.MOM.WW.MxGateway.slnx
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|
||||||
Supported languages: `csharp`, `json`, `bash`, `xml`, `sql`, `yaml`, `html`, `css`, `javascript`
|
Supported languages: `csharp`, `json`, `bash`, `powershell`, `xml`, `sql`,
|
||||||
|
`text`, `rust`, `python`, `go`, `proto`, `html`, `css`, `toml`.
|
||||||
|
|
||||||
### Code Snippets
|
### Code Snippets
|
||||||
|
|
||||||
**Length:** 5-25 lines is typical. Shorter for simple concepts, longer for complete examples.
|
**Length:** 5-25 lines is typical. Shorter for simple concepts, longer for
|
||||||
|
complete examples.
|
||||||
|
|
||||||
**Context:** Include enough to understand where the code lives:
|
**Context:** Include enough to understand where the code lives:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
// Good - shows class context
|
// Good - shows class context
|
||||||
public class TemplateInstanceActor : ReceiveActor
|
public sealed class GatewaySession
|
||||||
{
|
{
|
||||||
public TemplateInstanceActor(TemplateInstanceConfig config)
|
public GatewaySession(SessionId sessionId, WorkerPipeSession pipe)
|
||||||
{
|
{
|
||||||
Receive<StartProcessing>(Handle);
|
_sessionId = sessionId;
|
||||||
|
_pipe = pipe;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid - orphaned snippet
|
// Avoid - orphaned snippet
|
||||||
Receive<StartProcessing>(Handle);
|
_pipe = pipe;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Accuracy:** Only use code that exists in the codebase. Never invent examples.
|
**Accuracy:** Only use code that exists in the codebase. Never invent examples.
|
||||||
@@ -134,34 +143,34 @@ Use tables for structured reference information:
|
|||||||
```markdown
|
```markdown
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `Timeout` | `5000` | Milliseconds to wait |
|
| `MaxConcurrent` | `8` | Maximum simultaneous sessions |
|
||||||
| `RetryCount` | `3` | Number of retry attempts |
|
| `LeaseTimeoutSeconds` | `60` | Idle lease before sweep |
|
||||||
```
|
```
|
||||||
|
|
||||||
### Inline Code
|
### Inline Code
|
||||||
|
|
||||||
Use backticks for:
|
Use backticks for:
|
||||||
- Class names: `ScadaGatewayActor`
|
- Class names: `SessionManager`
|
||||||
- Method names: `HandleMessage()`
|
- Method names: `KillWorkerAsync()`
|
||||||
- File names: `appsettings.json`
|
- File names: `appsettings.json`
|
||||||
- Configuration keys: `ScadaBridge:Timeout`
|
- Configuration keys: `MxGateway:Sessions:MaxConcurrent`
|
||||||
- Command-line commands: `dotnet build`
|
- Command-line commands: `dotnet build`
|
||||||
|
|
||||||
### Links
|
### Links
|
||||||
|
|
||||||
Use relative paths for internal documentation:
|
Use relative paths for internal documentation:
|
||||||
```markdown
|
```markdown
|
||||||
[See the Actors guide](../Akka/Actors.md)
|
[See the architecture overview](./gateway.md)
|
||||||
[Configuration options](./Configuration.md)
|
[Configuration options](./docs/GatewayConfiguration.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Use descriptive link text:
|
Use descriptive link text:
|
||||||
```markdown
|
```markdown
|
||||||
<!-- Good -->
|
<!-- Good -->
|
||||||
See the [Actor Health Checks](../Akka/HealthChecks.md) documentation.
|
See the [Gateway Configuration](./docs/GatewayConfiguration.md) documentation.
|
||||||
|
|
||||||
<!-- Avoid -->
|
<!-- Avoid -->
|
||||||
See [here](../Akka/HealthChecks.md) for more.
|
See [here](./docs/GatewayConfiguration.md) for more.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure Conventions
|
## Structure Conventions
|
||||||
@@ -173,9 +182,10 @@ Every document starts with:
|
|||||||
2. 1-2 sentence description of purpose
|
2. 1-2 sentence description of purpose
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Actor Health Checks
|
# Worker STA Thread
|
||||||
|
|
||||||
Health checks monitor actor responsiveness and report status to the ASP.NET Core health check system.
|
The worker owns one MXAccess COM instance on a dedicated STA thread and pumps
|
||||||
|
Windows messages so MXAccess events deliver.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Section Organization
|
### Section Organization
|
||||||
@@ -194,15 +204,15 @@ Organize content from general to specific:
|
|||||||
Place code examples immediately after the concept they illustrate:
|
Place code examples immediately after the concept they illustrate:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Message Handling
|
## Session Close
|
||||||
|
|
||||||
Actors process messages using `Receive<T>` handlers:
|
The gateway closes a session by killing its worker behind the close gate:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
Receive<MyMessage>(msg => HandleMyMessage(msg));
|
await session.KillWorkerWithCloseGateAsync(cancellationToken);
|
||||||
```
|
```
|
||||||
|
|
||||||
Each handler processes one message type...
|
The close gate serializes concurrent close attempts...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Related Documentation Section
|
### Related Documentation Section
|
||||||
@@ -212,9 +222,9 @@ End each document with links to related topics:
|
|||||||
```markdown
|
```markdown
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Actor Patterns](./Patterns.md)
|
- [Sessions](./docs/Sessions.md)
|
||||||
- [Health Checks](../Operations/HealthChecks.md)
|
- [Worker STA Thread](./docs/WorkerSta.md)
|
||||||
- [Configuration](../Configuration/Akka.md)
|
- [Gateway Configuration](./docs/GatewayConfiguration.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
@@ -222,30 +232,33 @@ End each document with links to related topics:
|
|||||||
### Match Code Exactly
|
### Match Code Exactly
|
||||||
|
|
||||||
Use the exact names from source code:
|
Use the exact names from source code:
|
||||||
- `TemplateInstanceActor` not "Template Instance Actor"
|
- `MxStatusProxy` not "MX status proxy"
|
||||||
- `ScadaGatewayActor` not "SCADA Gateway Actor"
|
- `SessionManager` not "session manager"
|
||||||
- `IRequiredActor<T>` not "required actor interface"
|
- `OrphanWorkerTerminator` not "orphan worker terminator"
|
||||||
|
|
||||||
### Acronyms
|
### Acronyms
|
||||||
|
|
||||||
Spell out on first use, then use acronym:
|
Spell out on first use, then use acronym:
|
||||||
> OPC Unified Architecture (OPC UA) provides industrial communication standards. OPC UA servers expose...
|
> Single-threaded apartment (STA) threads serialize COM calls. STA message
|
||||||
|
> pumping lets MXAccess events deliver...
|
||||||
|
|
||||||
Common acronyms that don't need expansion:
|
Common acronyms that don't need expansion:
|
||||||
- API
|
- API
|
||||||
- JSON
|
- JSON
|
||||||
- SQL
|
- SQL
|
||||||
- HTTP/HTTPS
|
- HTTP/HTTPS
|
||||||
- REST
|
- COM
|
||||||
- JWT
|
- gRPC
|
||||||
|
- IPC
|
||||||
|
- STA
|
||||||
- UI
|
- UI
|
||||||
|
|
||||||
### File Paths
|
### File Paths
|
||||||
|
|
||||||
Use forward slashes and backticks:
|
Use forward slashes and backticks:
|
||||||
- `src/Infrastructure/Akka/Actors/`
|
- `src/ZB.MOM.WW.MxGateway.Server/`
|
||||||
- `appsettings.json`
|
- `appsettings.json`
|
||||||
- `Documentation/Akka/Overview.md`
|
- `docs/GatewayConfiguration.md`
|
||||||
|
|
||||||
## What to Avoid
|
## What to Avoid
|
||||||
|
|
||||||
@@ -260,13 +273,14 @@ The constructor creates a new instance of the class.
|
|||||||
<!-- Better - only document if there's something notable -->
|
<!-- Better - only document if there's something notable -->
|
||||||
## Constructor
|
## Constructor
|
||||||
|
|
||||||
The constructor accepts an `IActorRef` for the gateway actor, which must be resolved before actor creation.
|
The constructor accepts a `WorkerPipeSession`, which must be connected before
|
||||||
|
the session transitions out of `Handshaking`.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Don't Duplicate Source Code Comments
|
### Don't Duplicate Source Code Comments
|
||||||
|
|
||||||
If code has good comments, reference the file rather than copying:
|
If code has good comments, reference the file rather than copying:
|
||||||
> See `ScadaGatewayActor.cs` lines 45-60 for the message routing logic.
|
> See `SessionManager.cs` for the open-failure rollback order.
|
||||||
|
|
||||||
### Don't Include Temporary Information
|
### Don't Include Temporary Information
|
||||||
|
|
||||||
@@ -278,5 +292,12 @@ Assume readers know:
|
|||||||
- Dependency injection
|
- Dependency injection
|
||||||
- async/await
|
- async/await
|
||||||
- LINQ
|
- LINQ
|
||||||
- Entity Framework basics
|
|
||||||
- ASP.NET Core middleware pipeline
|
- ASP.NET Core middleware pipeline
|
||||||
|
- gRPC service basics
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Architecture overview](./gateway.md)
|
||||||
|
- [Gateway Configuration](./docs/GatewayConfiguration.md)
|
||||||
|
- [C# Style Guide](./docs/style-guides/CSharpStyleGuide.md)
|
||||||
|
- [Go Style Guide](./docs/style-guides/GoStyleGuide.md), [Java Style Guide](./docs/style-guides/JavaStyleGuide.md), [Python Style Guide](./docs/style-guides/PythonStyleGuide.md), [Rust Style Guide](./docs/style-guides/RustStyleGuide.md), [Protobuf Style Guide](./docs/style-guides/ProtobufStyleGuide.md)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
|
||||||
|
<Authors>Joseph Doherty</Authors>
|
||||||
|
<Company>ZB MOM WW</Company>
|
||||||
|
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||||
|
<Product>MxAccessGateway Client</Product>
|
||||||
|
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||||
|
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
|
||||||
|
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||||
|
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Default: do NOT pack. Each project opts in. -->
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -32,8 +32,6 @@ clients/dotnet/
|
|||||||
Commands/
|
Commands/
|
||||||
ZB.MOM.WW.MxGateway.Client.Tests/
|
ZB.MOM.WW.MxGateway.Client.Tests/
|
||||||
ZB.MOM.WW.MxGateway.Client.Tests.csproj
|
ZB.MOM.WW.MxGateway.Client.Tests.csproj
|
||||||
ZB.MOM.WW.MxGateway.Client.IntegrationTests/
|
|
||||||
ZB.MOM.WW.MxGateway.Client.IntegrationTests.csproj
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Target framework:
|
Target framework:
|
||||||
@@ -52,7 +50,6 @@ Expected packages:
|
|||||||
|
|
||||||
- `Grpc.Net.Client`
|
- `Grpc.Net.Client`
|
||||||
- `Google.Protobuf`
|
- `Google.Protobuf`
|
||||||
- `Grpc.Tools` for generation
|
|
||||||
- `Microsoft.Extensions.Logging.Abstractions`
|
- `Microsoft.Extensions.Logging.Abstractions`
|
||||||
- `System.CommandLine` or similar for CLI
|
- `System.CommandLine` or similar for CLI
|
||||||
- test framework: xUnit or NUnit
|
- test framework: xUnit or NUnit
|
||||||
@@ -107,6 +104,7 @@ public sealed class MxGatewayClientOptions
|
|||||||
public required string ApiKey { get; init; }
|
public required string ApiKey { get; init; }
|
||||||
public bool UseTls { get; init; }
|
public bool UseTls { get; init; }
|
||||||
public string? CaCertificatePath { get; init; }
|
public string? CaCertificatePath { get; init; }
|
||||||
|
public bool RequireCertificateValidation { get; init; }
|
||||||
public string? ServerNameOverride { get; init; }
|
public string? ServerNameOverride { get; init; }
|
||||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
@@ -124,6 +122,24 @@ or subscription changes because those calls can partially succeed in MXAccess.
|
|||||||
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
API key may be loaded from `MXGATEWAY_API_KEY` by the CLI, not implicitly by the
|
||||||
library constructor unless a helper explicitly says it does that.
|
library constructor unless a helper explicitly says it does that.
|
||||||
|
|
||||||
|
### TLS trust posture
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). To make that usable, TLS is **lenient by default**: when `UseTls` is set
|
||||||
|
and `CaCertificatePath` is empty, `CreateHttpHandler` installs a
|
||||||
|
`RemoteCertificateValidationCallback` that returns `true`, so the gateway's
|
||||||
|
self-signed certificate is accepted without verification.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `CaCertificatePath` to pin a CA — validated via a `CustomRootTrust`
|
||||||
|
`X509Chain` against that root, and the callback additionally rejects a
|
||||||
|
hostname/SAN mismatch (`RemoteCertificateNameMismatch`); or
|
||||||
|
- set `RequireCertificateValidation` to `true` to keep the default OS/system-trust
|
||||||
|
verification on a connection with no pinned CA.
|
||||||
|
|
||||||
|
Pinning a CA always wins over the lenient default.
|
||||||
|
|
||||||
## Auth Interceptor
|
## Auth Interceptor
|
||||||
|
|
||||||
Use a gRPC call credentials/interceptor layer to attach:
|
Use a gRPC call credentials/interceptor layer to attach:
|
||||||
|
|||||||
@@ -196,6 +196,54 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-las
|
|||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Browsing lazily
|
||||||
|
|
||||||
|
For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a
|
||||||
|
time instead of paging the full hierarchy. Pass an empty request for root objects;
|
||||||
|
subsequent calls supply `ParentGobjectId`, `ParentTagName`, or
|
||||||
|
`ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to
|
||||||
|
draw an expand triangle. Filter fields match `DiscoverHierarchy`. See
|
||||||
|
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||||
|
request and filter semantics.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
BrowseChildrenReply roots = await repository.BrowseChildrenAsync(
|
||||||
|
new BrowseChildrenRequest());
|
||||||
|
|
||||||
|
for (int i = 0; i < roots.Children.Count; i++)
|
||||||
|
{
|
||||||
|
GalaxyObject child = roots.Children[i];
|
||||||
|
bool hasChildren = roots.ChildHasChildren[i];
|
||||||
|
Console.WriteLine($"{child.TagName} expand={hasChildren}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
|
||||||
|
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
|
||||||
|
foreach (LazyBrowseNode root in roots)
|
||||||
|
{
|
||||||
|
if (root.HasChildrenHint)
|
||||||
|
{
|
||||||
|
await root.ExpandAsync();
|
||||||
|
}
|
||||||
|
foreach (LazyBrowseNode child in root.Children)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`BrowseAsync` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
||||||
@@ -239,6 +287,17 @@ Use TLS options for a secured gateway:
|
|||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TLS trust
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with
|
||||||
|
no pinned CA accepts whatever certificate the gateway presents. To verify
|
||||||
|
instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces
|
||||||
|
the certificate hostname/SAN match), or set `RequireCertificateValidation` to
|
||||||
|
force OS/system-trust verification without pinning. Use `ServerNameOverride` /
|
||||||
|
`--server-name` when the dialed host differs from the certificate SAN. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
## Integration Checks
|
## Integration Checks
|
||||||
|
|
||||||
Run live checks only when a gateway and MXAccess-backed worker are available:
|
Run live checks only when a gateway and MXAccess-backed worker are available:
|
||||||
@@ -251,6 +310,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
|||||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing as a NuGet Package
|
||||||
|
|
||||||
|
The client publishes to the internal Gitea NuGet feed at
|
||||||
|
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
|
||||||
|
|
||||||
|
Add the feed once:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
|
||||||
|
--name dohertj2-gitea \
|
||||||
|
--username <gitea-username> \
|
||||||
|
--password <gitea-token-or-password> \
|
||||||
|
--store-password-in-clear-text
|
||||||
|
````
|
||||||
|
|
||||||
|
Then add the package to your project:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
|
||||||
|
````
|
||||||
|
|
||||||
|
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
|
||||||
|
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BrowseChildrenSmokeTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
||||||
|
/// a consistent children/child-has-children count from a live gateway.
|
||||||
|
/// </summary>
|
||||||
|
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
||||||
|
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
||||||
|
{
|
||||||
|
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
|
||||||
|
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
|
||||||
|
|
||||||
|
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
|
||||||
|
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
|
||||||
|
|
||||||
|
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
|
||||||
|
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
|
||||||
|
|
||||||
|
Assert.True(reply.CacheSequence > 0UL);
|
||||||
|
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
|
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Gets the queue of discover hierarchy replies; dequeued in FIFO order.</summary>
|
||||||
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
|
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -122,6 +123,39 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
: DiscoverHierarchyReply);
|
: DiscoverHierarchyReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Records BrowseChildren RPC calls made by the client.</summary>
|
||||||
|
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Default reply returned from BrowseChildren when the queue is empty.</summary>
|
||||||
|
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Queue of replies returned from BrowseChildren; dequeued in FIFO order.</summary>
|
||||||
|
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
|
||||||
|
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the request and either throws a queued exception or returns the configured reply.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The BrowseChildrenRequest to process.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
|
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
BrowseChildrenCalls.Add((request, callOptions));
|
||||||
|
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
|
||||||
|
{
|
||||||
|
return Task.FromException<BrowseChildrenReply>(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(
|
||||||
|
BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
|
||||||
|
? reply
|
||||||
|
: BrowseChildrenReply);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="request">The acknowledge alarm request.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -219,6 +221,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records the query call and yields each enqueued snapshot.
|
/// Records the query call and yields each enqueued snapshot.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="request">The query active alarms request.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -234,12 +238,14 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues an acknowledge reply.</summary>
|
/// <summary>Enqueues an acknowledge reply.</summary>
|
||||||
|
/// <param name="reply">The acknowledge reply to enqueue.</param>
|
||||||
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
|
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
|
||||||
{
|
{
|
||||||
_acknowledgeReplies.Enqueue(reply);
|
_acknowledgeReplies.Enqueue(reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
|
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
|
||||||
|
/// <param name="snapshot">The snapshot to enqueue.</param>
|
||||||
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
|
||||||
{
|
{
|
||||||
_activeAlarmSnapshots.Add(snapshot);
|
_activeAlarmSnapshots.Add(snapshot);
|
||||||
@@ -248,6 +254,8 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records the stream-alarms call and yields each enqueued feed message.
|
/// Records the stream-alarms call and yields each enqueued feed message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="request">The stream alarms request.</param>
|
||||||
|
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -263,6 +271,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
|
/// <summary>Enqueues an alarm feed message to be yielded from StreamAlarmsAsync.</summary>
|
||||||
|
/// <param name="message">The alarm feed message to enqueue.</param>
|
||||||
public void AddAlarmFeedMessage(AlarmFeedMessage message)
|
public void AddAlarmFeedMessage(AlarmFeedMessage message)
|
||||||
{
|
{
|
||||||
_alarmFeedMessages.Add(message);
|
_alarmFeedMessages.Add(message);
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||||
{
|
{
|
||||||
@@ -212,6 +215,9 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
Assert.True(request.HistorizedOnly);
|
Assert.True(request.HistorizedOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LazyBrowseNodeTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
||||||
|
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Browse_NoParent_ReturnsRoots()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
|
||||||
|
childHasChildren: [true, false],
|
||||||
|
cacheSequence: 1));
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, roots.Count);
|
||||||
|
Assert.Equal("Plant", roots[0].Object.TagName);
|
||||||
|
Assert.True(roots[0].HasChildrenHint);
|
||||||
|
Assert.False(roots[0].IsExpanded);
|
||||||
|
Assert.Equal("Other", roots[1].Object.TagName);
|
||||||
|
Assert.False(roots[1].HasChildrenHint);
|
||||||
|
Assert.False(roots[1].IsExpanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Expand_PopulatesChildrenAndMarksExpanded()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(1, "Plant", isArea: true)],
|
||||||
|
childHasChildren: [true],
|
||||||
|
cacheSequence: 1));
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(10, "Line1")],
|
||||||
|
childHasChildren: [false],
|
||||||
|
cacheSequence: 1));
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
await roots[0].ExpandAsync();
|
||||||
|
|
||||||
|
Assert.True(roots[0].IsExpanded);
|
||||||
|
Assert.Single(roots[0].Children);
|
||||||
|
Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
|
||||||
|
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Expand_CalledTwice_NoSecondRpc()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(1, "Plant", isArea: true)],
|
||||||
|
childHasChildren: [true],
|
||||||
|
cacheSequence: 1));
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(10, "Line1")],
|
||||||
|
childHasChildren: [false],
|
||||||
|
cacheSequence: 1));
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
await roots[0].ExpandAsync();
|
||||||
|
await roots[0].ExpandAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(1, "Plant", isArea: true)],
|
||||||
|
childHasChildren: [true],
|
||||||
|
cacheSequence: 1));
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
|
||||||
|
// Queue the failure for the upcoming ExpandAsync call so it consumes
|
||||||
|
// the exception on its first RPC rather than the BrowseAsync above.
|
||||||
|
transport.BrowseChildrenExceptions.Enqueue(
|
||||||
|
new MxGatewayException(
|
||||||
|
"Parent not found",
|
||||||
|
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
|
||||||
|
Assert.False(roots[0].IsExpanded);
|
||||||
|
Assert.Empty(roots[0].Children);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Expand_MultiPageSiblings_GathersAllPages()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
// Roots
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(7, "Plant", isArea: true)],
|
||||||
|
childHasChildren: [true],
|
||||||
|
cacheSequence: 1));
|
||||||
|
// First child page (2 children) with a next token
|
||||||
|
BrowseChildrenReply childPage1 = BuildReply(
|
||||||
|
children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
|
||||||
|
childHasChildren: [false, false],
|
||||||
|
cacheSequence: 1);
|
||||||
|
childPage1.NextPageToken = "7:abc:2";
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(childPage1);
|
||||||
|
// Second child page (1 child) with no next token
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(72, "ChildC")],
|
||||||
|
childHasChildren: [false],
|
||||||
|
cacheSequence: 1));
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
await roots[0].ExpandAsync();
|
||||||
|
|
||||||
|
Assert.Equal(3, roots[0].Children.Count);
|
||||||
|
Assert.Equal(3, transport.BrowseChildrenCalls.Count);
|
||||||
|
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(1, "Plant", isArea: true)],
|
||||||
|
childHasChildren: [true],
|
||||||
|
cacheSequence: 7));
|
||||||
|
transport.BrowseChildrenReplies.Enqueue(BuildReply(
|
||||||
|
children: [BuildObject(2, "Mixer_001")],
|
||||||
|
childHasChildren: [false],
|
||||||
|
cacheSequence: 7));
|
||||||
|
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
|
||||||
|
|
||||||
|
// Fire ten concurrent expands of the same node.
|
||||||
|
Task[] tasks = Enumerable.Range(0, 10)
|
||||||
|
.Select(_ => roots[0].ExpandAsync())
|
||||||
|
.ToArray();
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
Assert.True(roots[0].IsExpanded);
|
||||||
|
Assert.Single(roots[0].Children);
|
||||||
|
// 1 roots fetch + exactly 1 expand fetch = 2 total
|
||||||
|
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Browse_WithFilter_ForwardsToRequest()
|
||||||
|
{
|
||||||
|
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||||
|
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.BrowseAsync(new BrowseChildrenOptions
|
||||||
|
{
|
||||||
|
TagNameGlob = "Mixer*",
|
||||||
|
AlarmBearingOnly = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
|
||||||
|
Assert.Equal("Mixer*", request.TagNameGlob);
|
||||||
|
Assert.True(request.AlarmBearingOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
|
||||||
|
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
|
||||||
|
|
||||||
|
private static BrowseChildrenReply BuildReply(
|
||||||
|
IReadOnlyList<GalaxyObject> children,
|
||||||
|
IReadOnlyList<bool> childHasChildren,
|
||||||
|
ulong cacheSequence)
|
||||||
|
{
|
||||||
|
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
|
||||||
|
reply.Children.AddRange(children);
|
||||||
|
reply.ChildHasChildren.AddRange(childHasChildren);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
|
||||||
|
=> new(transport.Options, transport);
|
||||||
|
|
||||||
|
private static FakeGalaxyRepositoryTransport CreateTransport()
|
||||||
|
=> new(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MxGatewayClientAlarmsTests
|
public sealed class MxGatewayClientAlarmsTests
|
||||||
{
|
{
|
||||||
|
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||||
{
|
{
|
||||||
@@ -46,6 +47,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||||
{
|
{
|
||||||
@@ -69,6 +71,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
cancellation.Token));
|
cancellation.Token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||||
{
|
{
|
||||||
@@ -93,6 +96,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
||||||
{
|
{
|
||||||
@@ -117,6 +121,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
Assert.Single(transport.QueryActiveAlarmsCalls);
|
Assert.Single(transport.QueryActiveAlarmsCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
||||||
{
|
{
|
||||||
@@ -136,6 +141,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
Assert.Equal("Tank01.", call.Request.AlarmFilterPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -519,6 +519,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
|
/// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
|
||||||
/// against exit code 0.
|
/// against exit code 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("stream-alarms")]
|
[InlineData("stream-alarms")]
|
||||||
[InlineData("acknowledge-alarm")]
|
[InlineData("acknowledge-alarm")]
|
||||||
@@ -716,6 +717,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
|
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
|
||||||
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("read-bulk")]
|
[InlineData("read-bulk")]
|
||||||
[InlineData("bench-read-bulk")]
|
[InlineData("bench-read-bulk")]
|
||||||
@@ -988,6 +990,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
||||||
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
|
||||||
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
||||||
|
|
||||||
/// <summary>List of received galaxy test connection requests.</summary>
|
/// <summary>List of received galaxy test connection requests.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Security;
|
||||||
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientTlsHandlerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
||||||
|
/// the handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
||||||
|
/// The callback must return true regardless of chain errors.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when RequireCertificateValidation is true, the callback is left null
|
||||||
|
/// so the OS trust store performs validation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
RequireCertificateValidation = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GalaxyRepositoryClientTlsHandlerTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when TLS is used with no pinned CA and RequireCertificateValidation is false (default),
|
||||||
|
/// the Galaxy client handler installs an accept-all callback so the gateway's self-signed cert is trusted.
|
||||||
|
/// The callback must return true regardless of chain errors.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
Assert.True(handler.SslOptions.RemoteCertificateValidationCallback!(null!, null!, null, SslPolicyErrors.RemoteCertificateChainErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that when RequireCertificateValidation is true, the Galaxy client callback is left null
|
||||||
|
/// so the OS trust store performs validation.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions options = new()
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("https://localhost:5120"),
|
||||||
|
ApiKey = "k",
|
||||||
|
UseTls = true,
|
||||||
|
RequireCertificateValidation = true,
|
||||||
|
};
|
||||||
|
using SocketsHttpHandler handler = GalaxyRepositoryClient.CreateHttpHandlerForTests(options);
|
||||||
|
Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
|
||||||
|
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BrowseChildrenOptions
|
||||||
|
{
|
||||||
|
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
|
||||||
|
public IReadOnlyList<int> CategoryIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
|
||||||
|
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
|
||||||
|
public string? TagNameGlob { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
|
||||||
|
public bool? IncludeAttributes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
|
||||||
|
public bool AlarmBearingOnly { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Restrict to children that have at least one historized attribute.</summary>
|
||||||
|
public bool HistorizedOnly { get; init; }
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
|||||||
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private const int DiscoverHierarchyPageSize = 5000;
|
private const int DiscoverHierarchyPageSize = 5000;
|
||||||
|
private const int BrowseChildrenPageSize = 500;
|
||||||
|
|
||||||
private readonly GrpcChannel? _channel;
|
private readonly GrpcChannel? _channel;
|
||||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||||
@@ -182,6 +183,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Discovers the Galaxy object hierarchy.</summary>
|
||||||
|
/// <param name="options">Client configuration options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
|
||||||
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyOptions options,
|
DiscoverHierarchyOptions options,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -274,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
|
||||||
|
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> BrowseAsync(null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
|
||||||
|
/// <param name="options">Browse filter options. Null applies no filter.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
|
||||||
|
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
|
||||||
|
BrowseChildrenOptions? options,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
|
||||||
|
List<LazyBrowseNode> roots = [];
|
||||||
|
string pageToken = string.Empty;
|
||||||
|
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
|
||||||
|
request.PageToken = pageToken;
|
||||||
|
BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
for (int i = 0; i < reply.Children.Count; i++)
|
||||||
|
{
|
||||||
|
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||||
|
roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = reply.NextPageToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
||||||
|
{
|
||||||
|
throw new MxGatewayException(
|
||||||
|
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
|
||||||
|
/// <param name="request">The browse-children request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The raw server reply.</returns>
|
||||||
|
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return ExecuteSafeUnaryAsync(
|
||||||
|
token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
BrowseChildrenRequest request = new()
|
||||||
|
{
|
||||||
|
PageSize = BrowseChildrenPageSize,
|
||||||
|
AlarmBearingOnly = options.AlarmBearingOnly,
|
||||||
|
HistorizedOnly = options.HistorizedOnly,
|
||||||
|
};
|
||||||
|
request.CategoryIds.Add(options.CategoryIds);
|
||||||
|
request.TemplateChainContains.Add(options.TemplateChainContains);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
|
||||||
|
{
|
||||||
|
request.TagNameGlob = options.TagNameGlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.IncludeAttributes.HasValue)
|
||||||
|
{
|
||||||
|
request.IncludeAttributes = options.IncludeAttributes.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
|
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
|
||||||
/// current state on subscribe so callers can prime their cache, then emits one event
|
/// current state on subscribe so callers can prime their cache, then emits one event
|
||||||
@@ -402,7 +490,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
{
|
{
|
||||||
@@ -422,6 +513,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
{
|
{
|
||||||
|
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (certificate is null)
|
if (certificate is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -437,6 +533,10 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
return customChain.Build(certificateToValidate);
|
return customChain.Build(certificateToValidate);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (!options.RequireCertificateValidation)
|
||||||
|
{
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await RawClient.BrowseChildrenAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (RpcException exception)
|
||||||
|
{
|
||||||
|
throw MapRpcException(exception, callOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
|
||||||
|
/// <param name="request">The browse children request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
|
BrowseChildrenRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The watch deploy events request.</param>
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
||||||
|
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
|
||||||
|
/// its direct children on demand. Expansion is one-shot: a second call is a
|
||||||
|
/// no-op. Pagination of large sibling sets is handled internally.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LazyBrowseNode
|
||||||
|
{
|
||||||
|
private readonly GalaxyRepositoryClient _client;
|
||||||
|
private readonly BrowseChildrenOptions _options;
|
||||||
|
private readonly List<LazyBrowseNode> _children = [];
|
||||||
|
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||||
|
private bool _isExpanded;
|
||||||
|
|
||||||
|
internal LazyBrowseNode(
|
||||||
|
GalaxyRepositoryClient client,
|
||||||
|
GalaxyObject @object,
|
||||||
|
bool hasChildrenHint,
|
||||||
|
BrowseChildrenOptions options)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
Object = @object;
|
||||||
|
HasChildrenHint = hasChildrenHint;
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The underlying Galaxy object for this node.</summary>
|
||||||
|
public GalaxyObject Object { get; }
|
||||||
|
|
||||||
|
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
|
||||||
|
public bool HasChildrenHint { get; }
|
||||||
|
|
||||||
|
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
|
||||||
|
public IReadOnlyList<LazyBrowseNode> Children => _children;
|
||||||
|
|
||||||
|
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
|
||||||
|
public bool IsExpanded => _isExpanded;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
|
||||||
|
/// Idempotent: subsequent calls are no-ops.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
|
||||||
|
/// (after the first completes) return immediately.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_isExpanded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_isExpanded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string pageToken = string.Empty;
|
||||||
|
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
|
||||||
|
request.ParentGobjectId = Object.GobjectId;
|
||||||
|
request.PageToken = pageToken;
|
||||||
|
|
||||||
|
BrowseChildrenReply reply = await _client
|
||||||
|
.BrowseChildrenRawAsync(request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
for (int i = 0; i < reply.Children.Count; i++)
|
||||||
|
{
|
||||||
|
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
|
||||||
|
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = reply.NextPageToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
|
||||||
|
{
|
||||||
|
throw new MxGatewayException(
|
||||||
|
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||||
|
|
||||||
|
_isExpanded = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_expandLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -315,7 +315,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
{
|
{
|
||||||
@@ -335,6 +338,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
||||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||||
{
|
{
|
||||||
|
if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (certificate is null)
|
if (certificate is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -350,6 +358,10 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
return customChain.Build(certificateToValidate);
|
return customChain.Build(certificateToValidate);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (!options.RequireCertificateValidation)
|
||||||
|
{
|
||||||
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MxGatewayClientContractInfo
|
public static class MxGatewayClientContractInfo
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||||
public const uint GatewayProtocolVersion =
|
public const uint GatewayProtocolVersion =
|
||||||
GatewayContractInfo.GatewayProtocolVersion;
|
GatewayContractInfo.GatewayProtocolVersion;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
|
||||||
public const uint WorkerProtocolVersion =
|
public const uint WorkerProtocolVersion =
|
||||||
GatewayContractInfo.WorkerProtocolVersion;
|
GatewayContractInfo.WorkerProtocolVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ public sealed class MxGatewayClientOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? CaCertificatePath { get; init; }
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
|
||||||
|
/// use the OS trust store. When false (default), the gateway certificate is
|
||||||
|
/// accepted without verification — appropriate for this internal tool's
|
||||||
|
/// auto-generated self-signed certificate. Pinning a CA always verifies.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireCertificateValidation { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the server name override for SNI during TLS handshake.
|
/// Gets the server name override for SNI during TLS handshake.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -47,6 +55,9 @@ public sealed class MxGatewayClientOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan? StreamTimeout { get; init; }
|
public TimeSpan? StreamTimeout { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum size in bytes for gRPC messages.
|
||||||
|
/// </summary>
|
||||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -16,4 +16,21 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||||
|
<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>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||||
|
<_Parameter1>ZB.MOM.WW.MxGateway.Client.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ clients/go/
|
|||||||
internal/generated/
|
internal/generated/
|
||||||
mxaccess_gateway.pb.go
|
mxaccess_gateway.pb.go
|
||||||
mxaccess_gateway_grpc.pb.go
|
mxaccess_gateway_grpc.pb.go
|
||||||
|
galaxy_repository.pb.go
|
||||||
|
galaxy_repository_grpc.pb.go
|
||||||
|
mxaccess_worker.pb.go
|
||||||
cmd/mxgw-go/
|
cmd/mxgw-go/
|
||||||
main.go
|
main.go
|
||||||
tests/
|
tests/
|
||||||
@@ -104,6 +107,23 @@ Support:
|
|||||||
- `credentials.NewClientTLSFromFile`,
|
- `credentials.NewClientTLSFromFile`,
|
||||||
- custom `tls.Config` for advanced callers.
|
- custom `tls.Config` for advanced callers.
|
||||||
|
|
||||||
|
### Trust posture
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). To make that usable, TLS is **lenient by default**: when `Plaintext` is
|
||||||
|
`false` and no `CACertFile`/`TLSConfig`/`TransportCredentials` is supplied,
|
||||||
|
`buildCredentials` dials with `tls.Config{InsecureSkipVerify: true}` (carrying
|
||||||
|
`ServerNameOverride` as the SNI when set), so the gateway's self-signed
|
||||||
|
certificate is accepted without verification.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `CACertFile` to pin a CA (full verification against that root), or
|
||||||
|
- set `RequireCertificateValidation: true` to verify against the OS/system trust
|
||||||
|
roots without pinning.
|
||||||
|
|
||||||
|
Pinning a CA always wins over the lenient default.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
`Events(ctx)` should return a receive channel of:
|
`Events(ctx)` should return a receive channel of:
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`Plaintext: false`) with
|
||||||
|
no `CACertFile`/`TLSConfig` accepts whatever certificate the gateway presents
|
||||||
|
(`InsecureSkipVerify`, with `ServerNameOverride` as the SNI when set). To verify
|
||||||
|
instead, set `CACertFile` to pin a CA, or set `RequireCertificateValidation:
|
||||||
|
true` to verify against the OS/system trust roots without pinning. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||||
@@ -121,6 +129,68 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
|
|||||||
the generated `*GalaxyObject` slice with each object's dynamic attributes
|
the generated `*GalaxyObject` slice with each object's dynamic attributes
|
||||||
populated for direct contract access.
|
populated for direct contract access.
|
||||||
|
|
||||||
|
### Browsing lazily
|
||||||
|
|
||||||
|
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
|
||||||
|
time instead of loading the full hierarchy. Pass an empty request for root
|
||||||
|
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
|
||||||
|
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
|
||||||
|
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
|
||||||
|
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||||
|
request and filter semantics.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
|
||||||
|
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, child := range reply.GetChildren() {
|
||||||
|
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```go
|
||||||
|
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
||||||
|
Plaintext: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer galaxy.Close()
|
||||||
|
|
||||||
|
roots, err := galaxy.Browse(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, root := range roots {
|
||||||
|
if root.HasChildrenHint() {
|
||||||
|
if err := root.Expand(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range root.Children() {
|
||||||
|
kind := "leaf"
|
||||||
|
if child.HasChildrenHint() {
|
||||||
|
kind = "has children"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`Browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
||||||
@@ -213,6 +283,38 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
|
|||||||
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing the Go client
|
||||||
|
|
||||||
|
The module is resolved directly from the git repo — no package registry:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
|
||||||
|
````
|
||||||
|
|
||||||
|
Then import:
|
||||||
|
|
||||||
|
````go
|
||||||
|
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||||
|
````
|
||||||
|
|
||||||
|
If your build environment cannot reach `gitea.dohertylan.com` directly,
|
||||||
|
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
|
||||||
|
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
|
||||||
|
for the internal module path.
|
||||||
|
|
||||||
|
## Releasing a new version
|
||||||
|
|
||||||
|
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
|
||||||
|
````
|
||||||
|
|
||||||
|
The script validates semver, refuses to tag with uncommitted tracked
|
||||||
|
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
|
||||||
|
pushes it to origin.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -824,6 +824,260 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BrowseChildrenRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
|
||||||
|
//
|
||||||
|
// Types that are valid to be assigned to Parent:
|
||||||
|
//
|
||||||
|
// *BrowseChildrenRequest_ParentGobjectId
|
||||||
|
// *BrowseChildrenRequest_ParentTagName
|
||||||
|
// *BrowseChildrenRequest_ParentContainedPath
|
||||||
|
Parent isBrowseChildrenRequest_Parent `protobuf_oneof:"parent"`
|
||||||
|
// Maximum number of direct children to return. Server default 500; cap 5000.
|
||||||
|
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
|
||||||
|
// Opaque token returned by a previous BrowseChildren response. Bound to the
|
||||||
|
// cache sequence, parent selector, and the filter set; a mismatch returns
|
||||||
|
// InvalidArgument.
|
||||||
|
PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
|
||||||
|
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
|
||||||
|
CategoryIds []int32 `protobuf:"varint,6,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
|
||||||
|
TemplateChainContains []string `protobuf:"bytes,7,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
|
||||||
|
TagNameGlob string `protobuf:"bytes,8,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
|
||||||
|
IncludeAttributes *bool `protobuf:"varint,9,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
|
||||||
|
AlarmBearingOnly bool `protobuf:"varint,10,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
|
||||||
|
HistorizedOnly bool `protobuf:"varint,11,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) Reset() {
|
||||||
|
*x = BrowseChildrenRequest{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[10]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*BrowseChildrenRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[10]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use BrowseChildrenRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*BrowseChildrenRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{10}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetParent() isBrowseChildrenRequest_Parent {
|
||||||
|
if x != nil {
|
||||||
|
return x.Parent
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetParentGobjectId() int32 {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentGobjectId); ok {
|
||||||
|
return x.ParentGobjectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetParentTagName() string {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentTagName); ok {
|
||||||
|
return x.ParentTagName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetParentContainedPath() string {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentContainedPath); ok {
|
||||||
|
return x.ParentContainedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetPageSize() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PageSize
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetPageToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PageToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetCategoryIds() []int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CategoryIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetTemplateChainContains() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TemplateChainContains
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetTagNameGlob() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.TagNameGlob
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetIncludeAttributes() bool {
|
||||||
|
if x != nil && x.IncludeAttributes != nil {
|
||||||
|
return *x.IncludeAttributes
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetAlarmBearingOnly() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.AlarmBearingOnly
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenRequest) GetHistorizedOnly() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.HistorizedOnly
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type isBrowseChildrenRequest_Parent interface {
|
||||||
|
isBrowseChildrenRequest_Parent()
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseChildrenRequest_ParentGobjectId struct {
|
||||||
|
ParentGobjectId int32 `protobuf:"varint,1,opt,name=parent_gobject_id,json=parentGobjectId,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseChildrenRequest_ParentTagName struct {
|
||||||
|
ParentTagName string `protobuf:"bytes,2,opt,name=parent_tag_name,json=parentTagName,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseChildrenRequest_ParentContainedPath struct {
|
||||||
|
ParentContainedPath string `protobuf:"bytes,3,opt,name=parent_contained_path,json=parentContainedPath,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*BrowseChildrenRequest_ParentGobjectId) isBrowseChildrenRequest_Parent() {}
|
||||||
|
|
||||||
|
func (*BrowseChildrenRequest_ParentTagName) isBrowseChildrenRequest_Parent() {}
|
||||||
|
|
||||||
|
func (*BrowseChildrenRequest_ParentContainedPath) isBrowseChildrenRequest_Parent() {}
|
||||||
|
|
||||||
|
type BrowseChildrenReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Direct children matching the filter, sorted areas-first then by
|
||||||
|
// case-insensitive display name (same order as the dashboard tree).
|
||||||
|
Children []*GalaxyObject `protobuf:"bytes,1,rep,name=children,proto3" json:"children,omitempty"`
|
||||||
|
// Non-empty when another page of siblings is available.
|
||||||
|
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
|
||||||
|
// Total matching direct children of the parent (post-filter).
|
||||||
|
TotalChildCount int32 `protobuf:"varint,3,opt,name=total_child_count,json=totalChildCount,proto3" json:"total_child_count,omitempty"`
|
||||||
|
// Parallel array, indexed with `children`. True when the child has at least
|
||||||
|
// one matching descendant under the same filter set. Lets a UI choose
|
||||||
|
// whether to draw an expand triangle without an extra round trip.
|
||||||
|
ChildHasChildren []bool `protobuf:"varint,4,rep,packed,name=child_has_children,json=childHasChildren,proto3" json:"child_has_children,omitempty"`
|
||||||
|
// Cache sequence this reply was projected from. Clients may pass it back as
|
||||||
|
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
|
||||||
|
CacheSequence uint64 `protobuf:"varint,5,opt,name=cache_sequence,json=cacheSequence,proto3" json:"cache_sequence,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) Reset() {
|
||||||
|
*x = BrowseChildrenReply{}
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[11]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*BrowseChildrenReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_galaxy_repository_proto_msgTypes[11]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use BrowseChildrenReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*BrowseChildrenReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_galaxy_repository_proto_rawDescGZIP(), []int{11}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) GetChildren() []*GalaxyObject {
|
||||||
|
if x != nil {
|
||||||
|
return x.Children
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) GetNextPageToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.NextPageToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) GetTotalChildCount() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TotalChildCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) GetChildHasChildren() []bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.ChildHasChildren
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *BrowseChildrenReply) GetCacheSequence() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CacheSequence
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
var File_galaxy_repository_proto protoreflect.FileDescriptor
|
var File_galaxy_repository_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_galaxy_repository_proto_rawDesc = "" +
|
const file_galaxy_repository_proto_rawDesc = "" +
|
||||||
@@ -897,12 +1151,35 @@ const file_galaxy_repository_proto_rawDesc = "" +
|
|||||||
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
|
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
|
||||||
"\ris_historized\x18\n" +
|
"\ris_historized\x18\n" +
|
||||||
" \x01(\bR\fisHistorized\x12\x19\n" +
|
" \x01(\bR\fisHistorized\x12\x19\n" +
|
||||||
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
|
"\bis_alarm\x18\v \x01(\bR\aisAlarm\"\x8c\x04\n" +
|
||||||
|
"\x15BrowseChildrenRequest\x12,\n" +
|
||||||
|
"\x11parent_gobject_id\x18\x01 \x01(\x05H\x00R\x0fparentGobjectId\x12(\n" +
|
||||||
|
"\x0fparent_tag_name\x18\x02 \x01(\tH\x00R\rparentTagName\x124\n" +
|
||||||
|
"\x15parent_contained_path\x18\x03 \x01(\tH\x00R\x13parentContainedPath\x12\x1b\n" +
|
||||||
|
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"page_token\x18\x05 \x01(\tR\tpageToken\x12!\n" +
|
||||||
|
"\fcategory_ids\x18\x06 \x03(\x05R\vcategoryIds\x126\n" +
|
||||||
|
"\x17template_chain_contains\x18\a \x03(\tR\x15templateChainContains\x12\"\n" +
|
||||||
|
"\rtag_name_glob\x18\b \x01(\tR\vtagNameGlob\x122\n" +
|
||||||
|
"\x12include_attributes\x18\t \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
|
||||||
|
"\x12alarm_bearing_only\x18\n" +
|
||||||
|
" \x01(\bR\x10alarmBearingOnly\x12'\n" +
|
||||||
|
"\x0fhistorized_only\x18\v \x01(\bR\x0ehistorizedOnlyB\b\n" +
|
||||||
|
"\x06parentB\x15\n" +
|
||||||
|
"\x13_include_attributes\"\xfe\x01\n" +
|
||||||
|
"\x13BrowseChildrenReply\x12>\n" +
|
||||||
|
"\bchildren\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\bchildren\x12&\n" +
|
||||||
|
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12*\n" +
|
||||||
|
"\x11total_child_count\x18\x03 \x01(\x05R\x0ftotalChildCount\x12,\n" +
|
||||||
|
"\x12child_has_children\x18\x04 \x03(\bR\x10childHasChildren\x12%\n" +
|
||||||
|
"\x0ecache_sequence\x18\x05 \x01(\x04R\rcacheSequence2\xb6\x04\n" +
|
||||||
"\x10GalaxyRepository\x12h\n" +
|
"\x10GalaxyRepository\x12h\n" +
|
||||||
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
|
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
|
||||||
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
|
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
|
||||||
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
|
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
|
||||||
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
|
||||||
|
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_galaxy_repository_proto_rawDescOnce sync.Once
|
file_galaxy_repository_proto_rawDescOnce sync.Once
|
||||||
@@ -916,7 +1193,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
|
|||||||
return file_galaxy_repository_proto_rawDescData
|
return file_galaxy_repository_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
|
||||||
var file_galaxy_repository_proto_goTypes = []any{
|
var file_galaxy_repository_proto_goTypes = []any{
|
||||||
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
||||||
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
|
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
|
||||||
@@ -928,30 +1205,35 @@ var file_galaxy_repository_proto_goTypes = []any{
|
|||||||
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
|
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
|
||||||
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
||||||
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
||||||
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
(*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
|
||||||
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
|
(*BrowseChildrenReply)(nil), // 11: galaxy_repository.v1.BrowseChildrenReply
|
||||||
|
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
|
||||||
|
(*wrapperspb.Int32Value)(nil), // 13: google.protobuf.Int32Value
|
||||||
}
|
}
|
||||||
var file_galaxy_repository_proto_depIdxs = []int32{
|
var file_galaxy_repository_proto_depIdxs = []int32{
|
||||||
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||||
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
|
13, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
|
||||||
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
|
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
|
||||||
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
||||||
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
|
12, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
|
||||||
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
12, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||||
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
|
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
|
||||||
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
|
||||||
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||||
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||||
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||||
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||||
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
|
||||||
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||||
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||||
11, // [11:15] is the sub-list for method output_type
|
5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||||
7, // [7:11] is the sub-list for method input_type
|
7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||||
7, // [7:7] is the sub-list for extension type_name
|
11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
|
||||||
7, // [7:7] is the sub-list for extension extendee
|
13, // [13:18] is the sub-list for method output_type
|
||||||
0, // [0:7] is the sub-list for field type_name
|
8, // [8:13] is the sub-list for method input_type
|
||||||
|
8, // [8:8] is the sub-list for extension type_name
|
||||||
|
8, // [8:8] is the sub-list for extension extendee
|
||||||
|
0, // [0:8] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_galaxy_repository_proto_init() }
|
func init() { file_galaxy_repository_proto_init() }
|
||||||
@@ -964,13 +1246,18 @@ func file_galaxy_repository_proto_init() {
|
|||||||
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
||||||
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
||||||
}
|
}
|
||||||
|
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
|
||||||
|
(*BrowseChildrenRequest_ParentGobjectId)(nil),
|
||||||
|
(*BrowseChildrenRequest_ParentTagName)(nil),
|
||||||
|
(*BrowseChildrenRequest_ParentContainedPath)(nil),
|
||||||
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 10,
|
NumMessages: 12,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v7.34.1
|
// - protoc v7.34.1
|
||||||
// source: galaxy_repository.proto
|
// source: galaxy_repository.proto
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
||||||
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
||||||
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
||||||
|
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
|
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
|
||||||
@@ -44,6 +45,11 @@ type GalaxyRepositoryClient interface {
|
|||||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
// older events because the client was too slow.
|
// older events because the client was too slow.
|
||||||
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
|
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
|
||||||
|
// Returns the direct children of a parent object (or the root objects when
|
||||||
|
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
// one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type galaxyRepositoryClient struct {
|
type galaxyRepositoryClient struct {
|
||||||
@@ -103,6 +109,16 @@ func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *Watc
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
|
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
|
||||||
|
|
||||||
|
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(BrowseChildrenReply)
|
||||||
|
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
|
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
|
||||||
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -122,6 +138,11 @@ type GalaxyRepositoryServer interface {
|
|||||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||||
// older events because the client was too slow.
|
// older events because the client was too slow.
|
||||||
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
|
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
|
||||||
|
// Returns the direct children of a parent object (or the root objects when
|
||||||
|
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
// one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
|
||||||
mustEmbedUnimplementedGalaxyRepositoryServer()
|
mustEmbedUnimplementedGalaxyRepositoryServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +165,9 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
|
|||||||
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
||||||
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
|
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
|
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
|
||||||
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
|
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -230,6 +254,24 @@ func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.Se
|
|||||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
|
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
|
||||||
|
|
||||||
|
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(BrowseChildrenRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
|
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -249,6 +291,10 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "DiscoverHierarchy",
|
MethodName: "DiscoverHierarchy",
|
||||||
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "BrowseChildren",
|
||||||
|
Handler: _GalaxyRepository_BrowseChildren_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -725,9 +725,10 @@ func (SessionState) EnumDescriptor() ([]byte, []int) {
|
|||||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
|
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public request shape for QueryActiveAlarms. session_id is currently unused
|
// Public request shape for QueryActiveAlarms.
|
||||||
// (the snapshot is session-less) but reserved so a future per-session view
|
// Clients may leave `session_id` empty; the gateway currently ignores it and
|
||||||
// can be added without a wire break.
|
// serves the session-less central-monitor cache. A future version may use it
|
||||||
|
// to scope the snapshot to one session.
|
||||||
type QueryActiveAlarmsRequest struct {
|
type QueryActiveAlarmsRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v7.34.1
|
// - protoc v7.34.1
|
||||||
// source: mxaccess_gateway.proto
|
// source: mxaccess_gateway.proto
|
||||||
|
|
||||||
@@ -50,6 +50,9 @@ type MxAccessGatewayClient interface {
|
|||||||
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
// have been missed during a transport blip. Streamed so callers can
|
// have been missed during a transport blip. Streamed so callers can
|
||||||
// begin processing without buffering the full set.
|
// begin processing without buffering the full set.
|
||||||
|
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
|
||||||
|
// snapshot to alarms whose `alarm_full_reference` starts with the given
|
||||||
|
// prefix; an empty prefix returns the full set.
|
||||||
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
|
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,9 @@ type MxAccessGatewayServer interface {
|
|||||||
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
// have been missed during a transport blip. Streamed so callers can
|
// have been missed during a transport blip. Streamed so callers can
|
||||||
// begin processing without buffering the full set.
|
// begin processing without buffering the full set.
|
||||||
|
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
|
||||||
|
// snapshot to alarms whose `alarm_full_reference` starts with the given
|
||||||
|
// prefix; an empty prefix returns the full set.
|
||||||
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
|
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
|
||||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,10 +222,22 @@ func resolveTransportCredentials(opts Options) (credentials.TransportCredentials
|
|||||||
return credentials.NewTLS(cfg), nil
|
return credentials.NewTLS(cfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return credentials.NewTLS(&tls.Config{
|
return credentials.NewTLS(tlsConfigForOptions(opts)), nil
|
||||||
MinVersion: tls.VersionTLS12,
|
}
|
||||||
ServerName: opts.ServerNameOverride,
|
|
||||||
}), nil
|
// tlsConfigForOptions returns the *tls.Config for the no-CA, no-custom-config TLS path.
|
||||||
|
// It returns nil when the caller should use a different credentials path (CA file or custom TLSConfig).
|
||||||
|
// Exposed as an internal helper so unit tests can assert the InsecureSkipVerify posture.
|
||||||
|
func tlsConfigForOptions(opts Options) *tls.Config {
|
||||||
|
// CA file and custom TLSConfig take their own paths in resolveTransportCredentials.
|
||||||
|
if opts.CACertFile != "" || opts.TLSConfig != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: opts.ServerNameOverride,
|
||||||
|
InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; self-signed gateway cert expected; opt-in strict via RequireCertificateValidation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsConfigFromOptions is the internal helper under test.
|
||||||
|
// It extracts the *tls.Config from the no-CA TLS path of resolveTransportCredentials.
|
||||||
|
// We exercise it directly to avoid needing a real dial target.
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_DefaultTrue(t *testing.T) {
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
})
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected non-nil tls.Config")
|
||||||
|
}
|
||||||
|
if !cfg.InsecureSkipVerify {
|
||||||
|
t.Error("InsecureSkipVerify should be true by default when no CA is pinned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_FalseWhenRequireCertificateValidation(t *testing.T) {
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
RequireCertificateValidation: true,
|
||||||
|
})
|
||||||
|
if cfg == nil {
|
||||||
|
t.Fatal("expected non-nil tls.Config")
|
||||||
|
}
|
||||||
|
if cfg.InsecureSkipVerify {
|
||||||
|
t.Error("InsecureSkipVerify should be false when RequireCertificateValidation is true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_FalseWhenCACertFileSet(t *testing.T) {
|
||||||
|
// When a CA file is pinned, the CA-verification path is taken instead.
|
||||||
|
// tlsConfigForOptions should return nil (the CA path does not use our helper).
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
CACertFile: "/some/ca.pem",
|
||||||
|
})
|
||||||
|
if cfg != nil {
|
||||||
|
t.Error("expected nil tls.Config when CACertFile is set (CA path taken)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSInsecureSkipVerify_FalseWhenCustomTLSConfig(t *testing.T) {
|
||||||
|
// When TLSConfig is supplied explicitly, our default skip-verify must not overwrite it.
|
||||||
|
custom := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||||
|
cfg := tlsConfigForOptions(Options{
|
||||||
|
Endpoint: "localhost:5120",
|
||||||
|
TLSConfig: custom,
|
||||||
|
})
|
||||||
|
if cfg != nil {
|
||||||
|
t.Error("expected nil tls.Config when TLSConfig is already set (custom config path taken)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package mxgateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
@@ -13,6 +15,14 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// browseChildrenPageSize is the per-request page size used by the lazy walker.
|
||||||
|
const browseChildrenPageSize = 500
|
||||||
|
|
||||||
|
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
|
||||||
|
// Mirrors the .NET client constant so large galaxies are not silently truncated
|
||||||
|
// by the server's default page cap.
|
||||||
|
const discoverHierarchyPageSize = 5000
|
||||||
|
|
||||||
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
||||||
// Galaxy Repository service exposed for callers that need direct contract
|
// Galaxy Repository service exposed for callers that need direct contract
|
||||||
// access.
|
// access.
|
||||||
@@ -40,6 +50,10 @@ type (
|
|||||||
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
||||||
// DeployEvent is one Galaxy Repository deploy event.
|
// DeployEvent is one Galaxy Repository deploy event.
|
||||||
DeployEvent = pb.DeployEvent
|
DeployEvent = pb.DeployEvent
|
||||||
|
// BrowseChildrenRequest is the request for BrowseChildren.
|
||||||
|
BrowseChildrenRequest = pb.BrowseChildrenRequest
|
||||||
|
// BrowseChildrenReply is the reply for BrowseChildren.
|
||||||
|
BrowseChildrenReply = pb.BrowseChildrenReply
|
||||||
)
|
)
|
||||||
|
|
||||||
// RawDeployEventStream is the generated WatchDeployEvents client stream.
|
// RawDeployEventStream is the generated WatchDeployEvents client stream.
|
||||||
@@ -146,16 +160,35 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
|
|||||||
|
|
||||||
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||||
// object's dynamic attributes. The objects are returned in the order supplied
|
// object's dynamic attributes. The objects are returned in the order supplied
|
||||||
// by the server.
|
// by the server. The call pages over the server's NextPageToken until the
|
||||||
|
// server signals it has no more results, matching the .NET client.
|
||||||
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
||||||
callCtx, cancel := c.callContext(ctx)
|
var objects []*GalaxyObject
|
||||||
defer cancel()
|
pageToken := ""
|
||||||
|
seen := map[string]struct{}{}
|
||||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
for {
|
||||||
if err != nil {
|
callCtx, cancel := c.callContext(ctx)
|
||||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
|
||||||
|
PageSize: discoverHierarchyPageSize,
|
||||||
|
PageToken: pageToken,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||||
|
}
|
||||||
|
objects = append(objects, reply.GetObjects()...)
|
||||||
|
pageToken = reply.GetNextPageToken()
|
||||||
|
if pageToken == "" {
|
||||||
|
return objects, nil
|
||||||
|
}
|
||||||
|
if _, dup := seen[pageToken]; dup {
|
||||||
|
return nil, &GatewayError{
|
||||||
|
Op: "galaxy discover hierarchy",
|
||||||
|
Err: fmt.Errorf("repeated page token %q", pageToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[pageToken] = struct{}{}
|
||||||
}
|
}
|
||||||
return reply.GetObjects(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
||||||
@@ -238,6 +271,206 @@ func (c *GalaxyClient) Close() error {
|
|||||||
return c.conn.Close()
|
return c.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
|
||||||
|
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
|
||||||
|
// The node is safe for concurrent use; concurrent Expand calls coalesce onto
|
||||||
|
// a single in-flight RPC and do not block snapshot accessors.
|
||||||
|
type LazyBrowseNode struct {
|
||||||
|
client *GalaxyClient
|
||||||
|
object *pb.GalaxyObject
|
||||||
|
hasChildrenHint bool
|
||||||
|
options BrowseChildrenOptions
|
||||||
|
|
||||||
|
// expandLock gates inspection and mutation of expand-coordination state
|
||||||
|
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
|
||||||
|
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
|
||||||
|
expandLock sync.Mutex
|
||||||
|
expanding bool
|
||||||
|
expandDone chan struct{}
|
||||||
|
expandErr error
|
||||||
|
|
||||||
|
// mu protects the children snapshot and isExpanded flag for concurrent
|
||||||
|
// Children() / IsExpanded() readers.
|
||||||
|
mu sync.RWMutex
|
||||||
|
children []*LazyBrowseNode
|
||||||
|
isExpanded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object returns the underlying GalaxyObject describing this node.
|
||||||
|
func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object }
|
||||||
|
|
||||||
|
// HasChildrenHint reports the server-supplied hint on whether this node has
|
||||||
|
// matching descendants under the current filter set.
|
||||||
|
func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
|
||||||
|
|
||||||
|
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
|
||||||
|
// an empty slice when Expand has not yet been called.
|
||||||
|
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
|
||||||
|
n.mu.RLock()
|
||||||
|
defer n.mu.RUnlock()
|
||||||
|
out := make([]*LazyBrowseNode, len(n.children))
|
||||||
|
copy(out, n.children)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpanded reports whether Expand has completed successfully on this node.
|
||||||
|
func (n *LazyBrowseNode) IsExpanded() bool {
|
||||||
|
n.mu.RLock()
|
||||||
|
defer n.mu.RUnlock()
|
||||||
|
return n.isExpanded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand fetches this node's direct children via BrowseChildren when they have
|
||||||
|
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
|
||||||
|
// and do not issue another RPC.
|
||||||
|
//
|
||||||
|
// Expand is safe to call concurrently from multiple goroutines: callers that
|
||||||
|
// arrive while an expansion is in flight wait on the active RPC and share its
|
||||||
|
// result instead of issuing a second RPC. The RPC itself runs without holding
|
||||||
|
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
|
||||||
|
// not blocked for the duration of the network round trip.
|
||||||
|
//
|
||||||
|
// Failure semantics: a failed expansion surfaces the same error to every
|
||||||
|
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
|
||||||
|
// false, no in-flight expansion). The next Expand call therefore retries with
|
||||||
|
// a fresh RPC; failures are not sticky.
|
||||||
|
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
|
||||||
|
// Fast path: already expanded.
|
||||||
|
n.mu.RLock()
|
||||||
|
if n.isExpanded {
|
||||||
|
n.mu.RUnlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n.mu.RUnlock()
|
||||||
|
|
||||||
|
// Either start a new expansion or wait on an existing one.
|
||||||
|
n.expandLock.Lock()
|
||||||
|
n.mu.RLock()
|
||||||
|
alreadyExpanded := n.isExpanded
|
||||||
|
n.mu.RUnlock()
|
||||||
|
if alreadyExpanded {
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n.expanding {
|
||||||
|
done := n.expandDone
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
n.expandLock.Lock()
|
||||||
|
err := n.expandErr
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n.expanding = true
|
||||||
|
n.expandDone = make(chan struct{})
|
||||||
|
done := n.expandDone
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
|
||||||
|
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
|
||||||
|
parentID := n.object.GetGobjectId()
|
||||||
|
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
n.mu.Lock()
|
||||||
|
n.children = children
|
||||||
|
n.isExpanded = true
|
||||||
|
n.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish result to waiters and clear the in-flight marker so a failed
|
||||||
|
// expansion can be retried by the next Expand call.
|
||||||
|
n.expandLock.Lock()
|
||||||
|
n.expandErr = err
|
||||||
|
n.expanding = false
|
||||||
|
close(done)
|
||||||
|
n.expandLock.Unlock()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
|
||||||
|
// have only their server-supplied hints populated; call Expand on each node to
|
||||||
|
// fetch its direct children. When opts is nil the server defaults apply.
|
||||||
|
func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) {
|
||||||
|
effective := BrowseChildrenOptions{}
|
||||||
|
if opts != nil {
|
||||||
|
effective = *opts
|
||||||
|
}
|
||||||
|
return c.browseChildrenInner(ctx, nil, effective)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseChildrenRaw issues a single BrowseChildren RPC and returns the raw
|
||||||
|
// reply for callers that need direct page-token control. Transport-level
|
||||||
|
// failures are wrapped in *GatewayError to match the rest of the client.
|
||||||
|
func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
reply, err := c.raw.BrowseChildren(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "galaxy browse children", Err: err}
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GalaxyClient) browseChildrenInner(
|
||||||
|
ctx context.Context,
|
||||||
|
parentGobjectID *int32,
|
||||||
|
opts BrowseChildrenOptions,
|
||||||
|
) ([]*LazyBrowseNode, error) {
|
||||||
|
var nodes []*LazyBrowseNode
|
||||||
|
pageToken := ""
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for {
|
||||||
|
req := &pb.BrowseChildrenRequest{
|
||||||
|
PageSize: browseChildrenPageSize,
|
||||||
|
PageToken: pageToken,
|
||||||
|
CategoryIds: opts.CategoryIds,
|
||||||
|
TemplateChainContains: opts.TemplateChainContains,
|
||||||
|
TagNameGlob: opts.TagNameGlob,
|
||||||
|
AlarmBearingOnly: opts.AlarmBearingOnly,
|
||||||
|
HistorizedOnly: opts.HistorizedOnly,
|
||||||
|
}
|
||||||
|
if parentGobjectID != nil {
|
||||||
|
req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID}
|
||||||
|
}
|
||||||
|
if opts.IncludeAttributes != nil {
|
||||||
|
req.IncludeAttributes = opts.IncludeAttributes
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := c.BrowseChildrenRaw(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, child := range reply.GetChildren() {
|
||||||
|
hasChildren := reply.GetChildHasChildren()
|
||||||
|
hint := i < len(hasChildren) && hasChildren[i]
|
||||||
|
nodes = append(nodes, &LazyBrowseNode{
|
||||||
|
client: c,
|
||||||
|
object: child,
|
||||||
|
hasChildrenHint: hint,
|
||||||
|
options: opts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = reply.GetNextPageToken()
|
||||||
|
if pageToken == "" {
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
if _, dup := seen[pageToken]; dup {
|
||||||
|
return nil, &GatewayError{
|
||||||
|
Op: "galaxy browse children",
|
||||||
|
Err: fmt.Errorf("repeated page token %q", pageToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[pageToken] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
timeout := c.opts.CallTimeout
|
timeout := c.opts.CallTimeout
|
||||||
if timeout == 0 {
|
if timeout == 0 {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/grpc/test/bufconn"
|
"google.golang.org/grpc/test/bufconn"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -144,6 +147,47 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
|
||||||
|
page1 := &pb.DiscoverHierarchyReply{
|
||||||
|
Objects: []*pb.GalaxyObject{
|
||||||
|
{GobjectId: 1, TagName: "A"},
|
||||||
|
{GobjectId: 2, TagName: "B"},
|
||||||
|
},
|
||||||
|
NextPageToken: "page-2",
|
||||||
|
TotalObjectCount: 3,
|
||||||
|
}
|
||||||
|
page2 := &pb.DiscoverHierarchyReply{
|
||||||
|
Objects: []*pb.GalaxyObject{
|
||||||
|
{GobjectId: 3, TagName: "C"},
|
||||||
|
},
|
||||||
|
TotalObjectCount: 3,
|
||||||
|
}
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
objs, err := client.DiscoverHierarchy(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverHierarchy: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(objs), 3; got != want {
|
||||||
|
t.Fatalf("len(objs) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if len(fake.discoverHierarchyCalls) != 2 {
|
||||||
|
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
|
||||||
|
}
|
||||||
|
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
|
||||||
|
t.Fatalf("first call PageSize = %d, want %d",
|
||||||
|
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
|
||||||
|
}
|
||||||
|
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
|
||||||
|
t.Fatalf("second call page token = %q, want %q",
|
||||||
|
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
||||||
fake := &fakeGalaxyServer{failTest: true}
|
fake := &fakeGalaxyServer{failTest: true}
|
||||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
@@ -370,15 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
|||||||
type fakeGalaxyServer struct {
|
type fakeGalaxyServer struct {
|
||||||
pb.UnimplementedGalaxyRepositoryServer
|
pb.UnimplementedGalaxyRepositoryServer
|
||||||
|
|
||||||
testReply *pb.TestConnectionReply
|
testReply *pb.TestConnectionReply
|
||||||
testAuth string
|
testAuth string
|
||||||
failTest bool
|
failTest bool
|
||||||
deployReply *pb.GetLastDeployTimeReply
|
deployReply *pb.GetLastDeployTimeReply
|
||||||
discoverReply *pb.DiscoverHierarchyReply
|
discoverReply *pb.DiscoverHierarchyReply
|
||||||
watchEvents []*pb.DeployEvent
|
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
|
||||||
watchRequest *pb.WatchDeployEventsRequest
|
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
|
||||||
watchSendInterval time.Duration
|
watchEvents []*pb.DeployEvent
|
||||||
watchHoldOpen bool
|
watchRequest *pb.WatchDeployEventsRequest
|
||||||
|
watchSendInterval time.Duration
|
||||||
|
watchHoldOpen bool
|
||||||
|
browseChildrenCalls []*pb.BrowseChildrenRequest
|
||||||
|
browseChildrenReplies []*pb.BrowseChildrenReply
|
||||||
|
browseChildrenError error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
||||||
@@ -400,6 +449,12 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
||||||
|
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
|
||||||
|
if len(s.discoverHierarchyReplies) > 0 {
|
||||||
|
reply := s.discoverHierarchyReplies[0]
|
||||||
|
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
if s.discoverReply != nil {
|
if s.discoverReply != nil {
|
||||||
return s.discoverReply, nil
|
return s.discoverReply, nil
|
||||||
}
|
}
|
||||||
@@ -425,3 +480,385 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
|
||||||
|
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
|
||||||
|
if s.browseChildrenError != nil {
|
||||||
|
err := s.browseChildrenError
|
||||||
|
s.browseChildrenError = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(s.browseChildrenReplies) == 0 {
|
||||||
|
return &pb.BrowseChildrenReply{}, nil
|
||||||
|
}
|
||||||
|
reply := s.browseChildrenReplies[0]
|
||||||
|
s.browseChildrenReplies = s.browseChildrenReplies[1:]
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
|
||||||
|
return &pb.GalaxyObject{
|
||||||
|
GobjectId: id,
|
||||||
|
TagName: tag,
|
||||||
|
BrowseName: tag,
|
||||||
|
IsArea: isArea,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
|
||||||
|
return &pb.BrowseChildrenReply{
|
||||||
|
TotalChildCount: int32(len(children)),
|
||||||
|
CacheSequence: seq,
|
||||||
|
Children: children,
|
||||||
|
ChildHasChildren: hasChildren,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
|
||||||
|
[]bool{true, false},
|
||||||
|
7,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
roots, err := client.Browse(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(roots), 2; got != want {
|
||||||
|
t.Fatalf("len(roots) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if roots[0].Object().GetTagName() != "Plant" {
|
||||||
|
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
|
||||||
|
}
|
||||||
|
if !roots[0].HasChildrenHint() {
|
||||||
|
t.Fatal("roots[0].HasChildrenHint = false, want true")
|
||||||
|
}
|
||||||
|
if roots[0].IsExpanded() {
|
||||||
|
t.Fatal("roots[0].IsExpanded = true, want false")
|
||||||
|
}
|
||||||
|
if roots[1].HasChildrenHint() {
|
||||||
|
t.Fatal("roots[1].HasChildrenHint = true, want false")
|
||||||
|
}
|
||||||
|
if len(fake.browseChildrenCalls) != 1 {
|
||||||
|
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
|
||||||
|
}
|
||||||
|
if fake.browseChildrenCalls[0].GetParent() != nil {
|
||||||
|
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||||
|
[]bool{true},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
|
||||||
|
[]bool{true, false},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
roots, err := client.Browse(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
if len(roots) != 1 {
|
||||||
|
t.Fatalf("len(roots) = %d, want 1", len(roots))
|
||||||
|
}
|
||||||
|
plant := roots[0]
|
||||||
|
if plant.IsExpanded() {
|
||||||
|
t.Fatal("plant.IsExpanded = true before Expand, want false")
|
||||||
|
}
|
||||||
|
if err := plant.Expand(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Expand: %v", err)
|
||||||
|
}
|
||||||
|
if !plant.IsExpanded() {
|
||||||
|
t.Fatal("plant.IsExpanded = false after Expand, want true")
|
||||||
|
}
|
||||||
|
children := plant.Children()
|
||||||
|
if len(children) != 2 {
|
||||||
|
t.Fatalf("len(children) = %d, want 2", len(children))
|
||||||
|
}
|
||||||
|
if children[0].Object().GetTagName() != "Area1" {
|
||||||
|
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
|
||||||
|
}
|
||||||
|
if !children[0].HasChildrenHint() {
|
||||||
|
t.Fatal("children[0].HasChildrenHint = false, want true")
|
||||||
|
}
|
||||||
|
if children[1].HasChildrenHint() {
|
||||||
|
t.Fatal("children[1].HasChildrenHint = true, want false")
|
||||||
|
}
|
||||||
|
if len(fake.browseChildrenCalls) != 2 {
|
||||||
|
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
|
||||||
|
}
|
||||||
|
parent := fake.browseChildrenCalls[1].GetParent()
|
||||||
|
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
|
||||||
|
}
|
||||||
|
if parentGobj.ParentGobjectId != 1 {
|
||||||
|
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||||
|
[]bool{true},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(10, "Area1", true)},
|
||||||
|
[]bool{false},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
roots, err := client.Browse(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
plant := roots[0]
|
||||||
|
if err := plant.Expand(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Expand #1: %v", err)
|
||||||
|
}
|
||||||
|
callsAfterFirst := len(fake.browseChildrenCalls)
|
||||||
|
if callsAfterFirst != 2 {
|
||||||
|
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
|
||||||
|
}
|
||||||
|
if err := plant.Expand(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Expand #2: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
|
||||||
|
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||||
|
[]bool{true},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
|
||||||
|
}
|
||||||
|
// The first Browse() consumes the first reply; the next call (Expand) will
|
||||||
|
// then hit browseChildrenError. We need the error to fire only on the second
|
||||||
|
// call, so seed the reply first and let the call sequence consume them in
|
||||||
|
// order. Because BrowseChildren in the fake consumes browseChildrenError
|
||||||
|
// before falling through to replies, swap the strategy: keep the root reply
|
||||||
|
// but have BrowseChildren return the error on the second call. We do this by
|
||||||
|
// emptying the reply list after the first Browse.
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// First call returns the error (because browseChildrenError takes precedence).
|
||||||
|
// To avoid that, clear it for the root call by performing a manual setup: we
|
||||||
|
// pre-stage replies first, then set the error after the first call. Easiest:
|
||||||
|
// pre-Browse() with error=nil, then set error before Expand.
|
||||||
|
fake.browseChildrenError = nil
|
||||||
|
roots, err := client.Browse(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
if len(roots) != 1 {
|
||||||
|
t.Fatalf("len(roots) = %d, want 1", len(roots))
|
||||||
|
}
|
||||||
|
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
|
||||||
|
|
||||||
|
err = roots[0].Expand(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expand: error = nil, want NotFound")
|
||||||
|
}
|
||||||
|
if status.Code(err) != codes.NotFound {
|
||||||
|
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
|
||||||
|
}
|
||||||
|
if roots[0].IsExpanded() {
|
||||||
|
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
|
||||||
|
firstPage := buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||||
|
[]bool{true},
|
||||||
|
7,
|
||||||
|
)
|
||||||
|
|
||||||
|
pageA := buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
|
||||||
|
[]bool{false, false},
|
||||||
|
7,
|
||||||
|
)
|
||||||
|
pageA.NextPageToken = "7:abc:2"
|
||||||
|
pageB := buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(12, "Child3", false)},
|
||||||
|
[]bool{false},
|
||||||
|
7,
|
||||||
|
)
|
||||||
|
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
roots, err := client.Browse(context.Background(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
if err := roots[0].Expand(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Expand: %v", err)
|
||||||
|
}
|
||||||
|
children := roots[0].Children()
|
||||||
|
if len(children) != 3 {
|
||||||
|
t.Fatalf("len(children) = %d, want 3", len(children))
|
||||||
|
}
|
||||||
|
if len(fake.browseChildrenCalls) != 3 {
|
||||||
|
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
|
||||||
|
}
|
||||||
|
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
|
||||||
|
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
buildBrowseReply(nil, nil, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
include := true
|
||||||
|
opts := &BrowseChildrenOptions{
|
||||||
|
CategoryIds: []int32{7, 9},
|
||||||
|
TemplateChainContains: []string{"$AppObject"},
|
||||||
|
TagNameGlob: "Tank*",
|
||||||
|
IncludeAttributes: &include,
|
||||||
|
AlarmBearingOnly: true,
|
||||||
|
HistorizedOnly: true,
|
||||||
|
}
|
||||||
|
if _, err := client.Browse(context.Background(), opts); err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.browseChildrenCalls) != 1 {
|
||||||
|
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
|
||||||
|
}
|
||||||
|
got := fake.browseChildrenCalls[0]
|
||||||
|
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
|
||||||
|
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
|
||||||
|
}
|
||||||
|
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
|
||||||
|
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
|
||||||
|
}
|
||||||
|
if got.GetTagNameGlob() != "Tank*" {
|
||||||
|
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
|
||||||
|
}
|
||||||
|
if !got.GetIncludeAttributes() {
|
||||||
|
t.Fatal("IncludeAttributes = false, want true")
|
||||||
|
}
|
||||||
|
if !got.GetAlarmBearingOnly() {
|
||||||
|
t.Fatal("AlarmBearingOnly = false, want true")
|
||||||
|
}
|
||||||
|
if !got.GetHistorizedOnly() {
|
||||||
|
t.Fatal("HistorizedOnly = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||||
|
// roots
|
||||||
|
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
|
||||||
|
// one expand: one child
|
||||||
|
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
roots, err := client.Browse(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Browse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 10)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
errs <- roots[0].Expand(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent Expand: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !roots[0].IsExpanded() {
|
||||||
|
t.Fatal("IsExpanded() = false after 10 concurrent expands")
|
||||||
|
}
|
||||||
|
if got, want := len(roots[0].Children()), 1; got != want {
|
||||||
|
t.Fatalf("len(children) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
// 1 roots fetch + exactly 1 expand fetch.
|
||||||
|
if got, want := len(fake.browseChildrenCalls), 2; got != want {
|
||||||
|
t.Fatalf("RPC count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
|
||||||
|
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
|
||||||
|
// will request a second page. Queue the same reply twice so the second response
|
||||||
|
// returns the same page token, triggering the duplicate-token guard.
|
||||||
|
page := buildBrowseReply(
|
||||||
|
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||||
|
[]bool{true},
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
page.NextPageToken = "1:abc:1"
|
||||||
|
|
||||||
|
fake := &fakeGalaxyServer{
|
||||||
|
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
|
||||||
|
}
|
||||||
|
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.Browse(context.Background(), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Browse: error = nil, want repeated-page-token error")
|
||||||
|
}
|
||||||
|
var gwErr *GatewayError
|
||||||
|
if !errors.As(err, &gwErr) {
|
||||||
|
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,32 @@ type Options struct {
|
|||||||
TransportCredentials credentials.TransportCredentials
|
TransportCredentials credentials.TransportCredentials
|
||||||
// DialOptions are appended to the gRPC dial options after the defaults.
|
// DialOptions are appended to the gRPC dial options after the defaults.
|
||||||
DialOptions []grpc.DialOption
|
DialOptions []grpc.DialOption
|
||||||
|
// RequireCertificateValidation forces TLS certificate verification even when
|
||||||
|
// no CACertFile is pinned. Default false: the gateway's self-signed cert is
|
||||||
|
// accepted without verification (internal-tool posture).
|
||||||
|
RequireCertificateValidation bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
|
||||||
|
// (*GalaxyClient).Browse and (*LazyBrowseNode).Expand. All fields are optional;
|
||||||
|
// the zero value matches the dashboard default (no filters, all attributes per
|
||||||
|
// the server default).
|
||||||
|
type BrowseChildrenOptions struct {
|
||||||
|
// CategoryIds restricts results to the listed Galaxy category ids when set.
|
||||||
|
CategoryIds []int32
|
||||||
|
// TemplateChainContains restricts results to objects whose template chain
|
||||||
|
// contains any of the listed template tag names.
|
||||||
|
TemplateChainContains []string
|
||||||
|
// TagNameGlob restricts results to objects whose tag name matches the glob
|
||||||
|
// pattern when non-empty.
|
||||||
|
TagNameGlob string
|
||||||
|
// IncludeAttributes overrides the server default for attribute inclusion when
|
||||||
|
// non-nil. The pointer form mirrors the proto's optional field.
|
||||||
|
IncludeAttributes *bool
|
||||||
|
// AlarmBearingOnly limits results to alarm-bearing objects when true.
|
||||||
|
AlarmBearingOnly bool
|
||||||
|
// HistorizedOnly limits results to historized objects when true.
|
||||||
|
HistorizedOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedactedAPIKey returns a display-safe representation of the configured API
|
// RedactedAPIKey returns a display-safe representation of the configured API
|
||||||
|
|||||||
@@ -112,6 +112,23 @@ Support:
|
|||||||
- custom CA certificate file,
|
- custom CA certificate file,
|
||||||
- server name override for test environments.
|
- server name override for test environments.
|
||||||
|
|
||||||
|
### Trust posture
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). To make that usable, TLS is **lenient by default**: when the channel is not
|
||||||
|
plaintext and no `caCertificatePath` is set, the client builds
|
||||||
|
`GrpcSslContexts.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)`
|
||||||
|
(grpc-netty-shaded), so the gateway's self-signed certificate is accepted without
|
||||||
|
verification.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `caCertificatePath` to pin a CA (full verification against that root), or
|
||||||
|
- set `requireCertificateValidation` to `true` to verify against the JVM trust
|
||||||
|
store without pinning.
|
||||||
|
|
||||||
|
Pinning a CA always wins over the lenient default.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Support both:
|
Support both:
|
||||||
|
|||||||
+96
-2
@@ -57,6 +57,16 @@ try (MxGatewayClient client = MxGatewayClient.connect(options);
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI), so
|
||||||
|
the client is **lenient by default**: a TLS connection (`plaintext(false)`) with
|
||||||
|
no `caCertificatePath` accepts whatever certificate the gateway presents (via
|
||||||
|
grpc-netty-shaded's `InsecureTrustManagerFactory`). To verify instead, set
|
||||||
|
`caCertificatePath` to pin a CA, or set `requireCertificateValidation(true)` to
|
||||||
|
verify against the JVM trust store without pinning. Use `serverNameOverride` /
|
||||||
|
`--server-name-override` when the dialed host differs from the certificate SAN.
|
||||||
|
See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
Use `rawBlockingStub`, `rawFutureStub`, `rawAsyncStub`, `openSessionRaw`,
|
||||||
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
`closeSessionRaw`, `invoke`, and raw session helper methods when tests need the
|
||||||
underlying protobuf messages. `MxGatewayCommandException` and
|
underlying protobuf messages. `MxGatewayCommandException` and
|
||||||
@@ -116,6 +126,59 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localh
|
|||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Browsing lazily
|
||||||
|
|
||||||
|
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
|
||||||
|
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
|
||||||
|
default request for root objects; subsequent calls set `parentGobjectId`,
|
||||||
|
`parentTagName`, or `parentContainedPath`. Filter fields match
|
||||||
|
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
|
||||||
|
`getChildHasChildrenList()` so you know which nodes to expand. See
|
||||||
|
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||||
|
request and filter semantics. This snippet documents the API as it appears once
|
||||||
|
the Java client is regenerated on the Windows host.
|
||||||
|
|
||||||
|
```java
|
||||||
|
BrowseChildrenReply reply = galaxy.browseChildren(
|
||||||
|
BrowseChildrenRequest.newBuilder().build());
|
||||||
|
|
||||||
|
List<GalaxyObject> children = reply.getChildrenList();
|
||||||
|
List<Boolean> hasChildren = reply.getChildHasChildrenList();
|
||||||
|
for (int i = 0; i < children.size(); i++) {
|
||||||
|
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```java
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("localhost:5000")
|
||||||
|
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
|
||||||
|
.plaintext(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
|
||||||
|
List<LazyBrowseNode> roots = galaxy.browse();
|
||||||
|
for (LazyBrowseNode root : roots) {
|
||||||
|
if (root.hasChildrenHint()) {
|
||||||
|
root.expand();
|
||||||
|
}
|
||||||
|
for (LazyBrowseNode child : root.getChildren()) {
|
||||||
|
String kind = child.hasChildrenHint() ? "has children" : "leaf";
|
||||||
|
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||||
@@ -179,8 +242,8 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 -
|
|||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --alarm-reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
|
||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -229,6 +292,37 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
|||||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing from the Gitea Maven repository
|
||||||
|
|
||||||
|
The client publishes to the internal Gitea Maven repository at
|
||||||
|
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
|
||||||
|
|
||||||
|
In your consumer project's `build.gradle`:
|
||||||
|
|
||||||
|
````groovy
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||||
|
credentials {
|
||||||
|
username = System.getenv('GITEA_USERNAME')
|
||||||
|
password = System.getenv('GITEA_TOKEN')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
|
||||||
|
}
|
||||||
|
````
|
||||||
|
|
||||||
|
To publish a new version from this repo:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
export GITEA_USERNAME=dohertj2
|
||||||
|
export GITEA_TOKEN=<your-gitea-token>
|
||||||
|
gradle :zb-mom-ww-mxgateway-client:publish
|
||||||
|
````
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -37,4 +37,44 @@ subprojects {
|
|||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pluginManager.withPlugin('maven-publish') {
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
maven(MavenPublication) {
|
||||||
|
from components.java
|
||||||
|
pom {
|
||||||
|
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||||
|
description = 'MxAccessGateway Java client'
|
||||||
|
scm {
|
||||||
|
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||||
|
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
|
||||||
|
}
|
||||||
|
developers {
|
||||||
|
developer {
|
||||||
|
id = 'dohertj2'
|
||||||
|
name = 'Joseph Doherty'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
licenses {
|
||||||
|
license {
|
||||||
|
name = 'Proprietary'
|
||||||
|
distribution = 'repo'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
name = 'GiteaPackages'
|
||||||
|
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||||
|
credentials {
|
||||||
|
username = System.getenv('GITEA_USERNAME') ?: ''
|
||||||
|
password = System.getenv('GITEA_TOKEN') ?: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
|
|||||||
+111
@@ -142,6 +142,37 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
return getWatchDeployEventsMethod;
|
return getWatchDeployEventsMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod;
|
||||||
|
|
||||||
|
@io.grpc.stub.annotations.RpcMethod(
|
||||||
|
fullMethodName = SERVICE_NAME + '/' + "BrowseChildren",
|
||||||
|
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest.class,
|
||||||
|
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply.class,
|
||||||
|
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod() {
|
||||||
|
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod;
|
||||||
|
if ((getBrowseChildrenMethod = GalaxyRepositoryGrpc.getBrowseChildrenMethod) == null) {
|
||||||
|
synchronized (GalaxyRepositoryGrpc.class) {
|
||||||
|
if ((getBrowseChildrenMethod = GalaxyRepositoryGrpc.getBrowseChildrenMethod) == null) {
|
||||||
|
GalaxyRepositoryGrpc.getBrowseChildrenMethod = getBrowseChildrenMethod =
|
||||||
|
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>newBuilder()
|
||||||
|
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
|
||||||
|
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "BrowseChildren"))
|
||||||
|
.setSampledToLocalTracing(true)
|
||||||
|
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest.getDefaultInstance()))
|
||||||
|
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply.getDefaultInstance()))
|
||||||
|
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("BrowseChildren"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getBrowseChildrenMethod;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new async stub that supports all call types for the service
|
* Creates a new async stub that supports all call types for the service
|
||||||
*/
|
*/
|
||||||
@@ -246,6 +277,19 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
|
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
|
||||||
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Returns the direct children of a parent object (or the root objects when
|
||||||
|
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
* one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
default void browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> responseObserver) {
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getBrowseChildrenMethod(), responseObserver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,6 +370,20 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
|
||||||
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
|
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Returns the direct children of a parent object (or the root objects when
|
||||||
|
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
* one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public void browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request,
|
||||||
|
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> responseObserver) {
|
||||||
|
io.grpc.stub.ClientCalls.asyncUnaryCall(
|
||||||
|
getChannel().newCall(getBrowseChildrenMethod(), getCallOptions()), request, responseObserver);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,6 +445,19 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
|
||||||
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
|
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Returns the direct children of a parent object (or the root objects when
|
||||||
|
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
* one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) throws io.grpc.StatusException {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
|
||||||
|
getChannel(), getBrowseChildrenMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -447,6 +518,19 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
|
||||||
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
|
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Returns the direct children of a parent object (or the root objects when
|
||||||
|
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
* one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.blockingUnaryCall(
|
||||||
|
getChannel(), getBrowseChildrenMethod(), getCallOptions(), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -494,12 +578,27 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
|
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
* Returns the direct children of a parent object (or the root objects when
|
||||||
|
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
* one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> browseChildren(
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) {
|
||||||
|
return io.grpc.stub.ClientCalls.futureUnaryCall(
|
||||||
|
getChannel().newCall(getBrowseChildrenMethod(), getCallOptions()), request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final int METHODID_TEST_CONNECTION = 0;
|
private static final int METHODID_TEST_CONNECTION = 0;
|
||||||
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
|
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
|
||||||
private static final int METHODID_DISCOVER_HIERARCHY = 2;
|
private static final int METHODID_DISCOVER_HIERARCHY = 2;
|
||||||
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
|
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
|
||||||
|
private static final int METHODID_BROWSE_CHILDREN = 4;
|
||||||
|
|
||||||
private static final class MethodHandlers<Req, Resp> implements
|
private static final class MethodHandlers<Req, Resp> implements
|
||||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||||
@@ -534,6 +633,10 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
|
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
|
||||||
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
|
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
|
||||||
break;
|
break;
|
||||||
|
case METHODID_BROWSE_CHILDREN:
|
||||||
|
serviceImpl.browseChildren((galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest) request,
|
||||||
|
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>) responseObserver);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new AssertionError();
|
throw new AssertionError();
|
||||||
}
|
}
|
||||||
@@ -580,6 +683,13 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
|
||||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
|
||||||
service, METHODID_WATCH_DEPLOY_EVENTS)))
|
service, METHODID_WATCH_DEPLOY_EVENTS)))
|
||||||
|
.addMethod(
|
||||||
|
getBrowseChildrenMethod(),
|
||||||
|
io.grpc.stub.ServerCalls.asyncUnaryCall(
|
||||||
|
new MethodHandlers<
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
|
||||||
|
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>(
|
||||||
|
service, METHODID_BROWSE_CHILDREN)))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +742,7 @@ public final class GalaxyRepositoryGrpc {
|
|||||||
.addMethod(getGetLastDeployTimeMethod())
|
.addMethod(getGetLastDeployTimeMethod())
|
||||||
.addMethod(getDiscoverHierarchyMethod())
|
.addMethod(getDiscoverHierarchyMethod())
|
||||||
.addMethod(getWatchDeployEventsMethod())
|
.addMethod(getWatchDeployEventsMethod())
|
||||||
|
.addMethod(getBrowseChildrenMethod())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3650
-14
File diff suppressed because it is too large
Load Diff
+138
-4
@@ -33,6 +33,7 @@ import java.util.Optional;
|
|||||||
import java.util.concurrent.ArrayBlockingQueue;
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
import java.util.concurrent.BlockingQueue;
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
@@ -119,7 +120,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
|
||||||
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
|
||||||
commandLine.addSubcommand("version", new VersionCommand());
|
commandLine.addSubcommand("version", new VersionCommand());
|
||||||
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
|
||||||
@@ -154,6 +155,120 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
/** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
|
||||||
private static final Object ALARM_FEED_END = new Object();
|
private static final Object ALARM_FEED_END = new Object();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenises a single batch-mode stdin line into the argv that the inner
|
||||||
|
* {@link CommandLine} should execute. Honours single-quoted, double-quoted,
|
||||||
|
* and backslash-escaped runs so values that contain spaces (e.g.
|
||||||
|
* {@code --comment "needs verification"}) survive intact — the old
|
||||||
|
* implementation used {@code split("\\s+")} which shredded any quoted
|
||||||
|
* argument mid-string (Client.Java-034).
|
||||||
|
*
|
||||||
|
* <p>Rules (a small POSIX-like shell tokenizer; no variable expansion,
|
||||||
|
* command substitution, globbing, or backtick handling):
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Outside quotes, runs of whitespace separate tokens.</li>
|
||||||
|
* <li>{@code "..."} groups a sequence into one token; the surrounding
|
||||||
|
* quotes are removed. Inside double quotes a backslash escapes
|
||||||
|
* {@code \\}, {@code "}, and a literal newline; other characters
|
||||||
|
* are taken literally (so {@code \n} is the two characters
|
||||||
|
* backslash-n).</li>
|
||||||
|
* <li>{@code '...'} groups a sequence into one token; the surrounding
|
||||||
|
* quotes are removed. Inside single quotes nothing is escaped —
|
||||||
|
* the run is literal until the matching single quote.</li>
|
||||||
|
* <li>Outside quotes, backslash escapes the next character (including
|
||||||
|
* whitespace, so {@code needs\ verification} is one token).</li>
|
||||||
|
* <li>An unterminated quote or a trailing backslash throws
|
||||||
|
* {@link IllegalArgumentException} so the batch loop surfaces it
|
||||||
|
* as a JSON error instead of silently emitting wrong args.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Empty input (or input that contains only whitespace) returns an
|
||||||
|
* empty array so callers can skip the line.
|
||||||
|
*/
|
||||||
|
static String[] tokenizeBatchLine(String line) {
|
||||||
|
List<String> tokens = new ArrayList<>();
|
||||||
|
StringBuilder current = new StringBuilder();
|
||||||
|
boolean inToken = false;
|
||||||
|
// 0 = outside, 1 = inside single quotes, 2 = inside double quotes
|
||||||
|
int quoteMode = 0;
|
||||||
|
int length = line.length();
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
char c = line.charAt(i);
|
||||||
|
if (quoteMode == 1) {
|
||||||
|
if (c == '\'') {
|
||||||
|
quoteMode = 0;
|
||||||
|
} else {
|
||||||
|
current.append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quoteMode == 2) {
|
||||||
|
if (c == '\\') {
|
||||||
|
if (i + 1 >= length) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"batch tokenizer: trailing backslash inside double-quoted string");
|
||||||
|
}
|
||||||
|
char next = line.charAt(i + 1);
|
||||||
|
if (next == '\\' || next == '"' || next == '\n') {
|
||||||
|
current.append(next);
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// POSIX rule: inside double quotes a backslash is
|
||||||
|
// literal unless it precedes \, ", $, `, or newline.
|
||||||
|
current.append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') {
|
||||||
|
quoteMode = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current.append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Outside any quotes.
|
||||||
|
if (c == '\'') {
|
||||||
|
quoteMode = 1;
|
||||||
|
inToken = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') {
|
||||||
|
quoteMode = 2;
|
||||||
|
inToken = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '\\') {
|
||||||
|
if (i + 1 >= length) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"batch tokenizer: trailing backslash outside quotes");
|
||||||
|
}
|
||||||
|
current.append(line.charAt(i + 1));
|
||||||
|
i++;
|
||||||
|
inToken = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Character.isWhitespace(c)) {
|
||||||
|
if (inToken) {
|
||||||
|
tokens.add(current.toString());
|
||||||
|
current.setLength(0);
|
||||||
|
inToken = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current.append(c);
|
||||||
|
inToken = true;
|
||||||
|
}
|
||||||
|
if (quoteMode != 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"batch tokenizer: unterminated " + (quoteMode == 1 ? "single" : "double") + " quote");
|
||||||
|
}
|
||||||
|
if (inToken) {
|
||||||
|
tokens.add(current.toString());
|
||||||
|
}
|
||||||
|
return tokens.toArray(new String[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads one CLI invocation per stdin line, executes each via a fresh
|
* Reads one CLI invocation per stdin line, executes each via a fresh
|
||||||
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
|
||||||
@@ -183,8 +298,8 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
if (line.isEmpty()) {
|
if (line.isEmpty()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
String[] args = line.trim().split("\\s+");
|
String[] args = tokenizeBatchLine(line);
|
||||||
if (args.length == 0 || (args.length == 1 && args[0].isEmpty())) {
|
if (args.length == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
StringWriter cmdOut = new StringWriter();
|
StringWriter cmdOut = new StringWriter();
|
||||||
@@ -1079,11 +1194,29 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
||||||
.setAlarmFilterPrefix(filterPrefix)
|
.setAlarmFilterPrefix(filterPrefix)
|
||||||
.build();
|
.build();
|
||||||
|
// Client.Java-033 — fail-fast on overflow. A bare
|
||||||
|
// queue.offer(value) silently drops messages past capacity,
|
||||||
|
// which violates the JavaStyleGuide "do not drop events"
|
||||||
|
// contract and lets the CLI exit 0 on a truncated feed.
|
||||||
|
// Mirrors MxEventStream's overflow branch: detect a failed
|
||||||
|
// offer, cancel the subscription, drain the buffer, then
|
||||||
|
// queue an explicit overflow exception followed by the END
|
||||||
|
// sentinel so the drain loop surfaces a non-zero exit.
|
||||||
|
AtomicReference<MxGatewayAlarmFeedSubscription> subscriptionRef = new AtomicReference<>();
|
||||||
MxGatewayAlarmFeedSubscription subscription =
|
MxGatewayAlarmFeedSubscription subscription =
|
||||||
client.streamAlarms(request, new StreamObserver<>() {
|
client.streamAlarms(request, new StreamObserver<>() {
|
||||||
@Override
|
@Override
|
||||||
public void onNext(AlarmFeedMessage value) {
|
public void onNext(AlarmFeedMessage value) {
|
||||||
queue.offer(value);
|
if (!queue.offer(value)) {
|
||||||
|
MxGatewayAlarmFeedSubscription sub = subscriptionRef.get();
|
||||||
|
if (sub != null) {
|
||||||
|
sub.cancel();
|
||||||
|
}
|
||||||
|
queue.clear();
|
||||||
|
queue.offer(new IllegalStateException(
|
||||||
|
"stream-alarms queue overflowed (capacity 1024); consumer too slow"));
|
||||||
|
queue.offer(ALARM_FEED_END);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1096,6 +1229,7 @@ public final class MxGatewayCli implements Callable<Integer> {
|
|||||||
queue.offer(ALARM_FEED_END);
|
queue.offer(ALARM_FEED_END);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
subscriptionRef.set(subscription);
|
||||||
try {
|
try {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
+216
@@ -225,6 +225,89 @@ final class MxGatewayCliTests {
|
|||||||
assertTrue(run.errors().contains("--reference"), run.errors());
|
assertTrue(run.errors().contains("--reference"), run.errors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readmeDocumentedStreamAlarmsExampleParsesCleanly() {
|
||||||
|
// Client.Java-032 regression — the README's stream-alarms example
|
||||||
|
// (clients/java/README.md:182) must round-trip through picocli's
|
||||||
|
// parser without a parse error. Before the fix, the example used
|
||||||
|
// a non-existent --session-id option and picocli failed at parse
|
||||||
|
// time. This test pins the exact tokens documented in the README.
|
||||||
|
String[] args = {
|
||||||
|
"stream-alarms",
|
||||||
|
"--endpoint",
|
||||||
|
"localhost:5000",
|
||||||
|
"--api-key-env",
|
||||||
|
"MXGATEWAY_API_KEY",
|
||||||
|
"--plaintext",
|
||||||
|
"--filter-prefix",
|
||||||
|
"Galaxy",
|
||||||
|
"--limit",
|
||||||
|
"1",
|
||||||
|
"--json"
|
||||||
|
};
|
||||||
|
assertReadmeExampleParses(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readmeDocumentedAcknowledgeAlarmExampleParsesCleanly() {
|
||||||
|
// Client.Java-032 regression — the README's acknowledge-alarm
|
||||||
|
// example (clients/java/README.md:183) must parse without error.
|
||||||
|
// Before the fix it used --session-id (no such option) and
|
||||||
|
// --alarm-reference (the real option is --reference), so picocli
|
||||||
|
// rejected the invocation immediately.
|
||||||
|
String[] args = {
|
||||||
|
"acknowledge-alarm",
|
||||||
|
"--endpoint",
|
||||||
|
"localhost:5000",
|
||||||
|
"--api-key-env",
|
||||||
|
"MXGATEWAY_API_KEY",
|
||||||
|
"--plaintext",
|
||||||
|
"--reference",
|
||||||
|
"\\Galaxy\\Area001.Pump001.PumpFault",
|
||||||
|
"--json"
|
||||||
|
};
|
||||||
|
assertReadmeExampleParses(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the given args through the production picocli {@link CommandLine}
|
||||||
|
* and asserts no parser error, no unknown option, and no missing required
|
||||||
|
* option. Does not execute the command body — only the option / subcommand
|
||||||
|
* parser is exercised, so no network call is made.
|
||||||
|
*/
|
||||||
|
private static void assertReadmeExampleParses(String[] args) {
|
||||||
|
picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
|
||||||
|
try {
|
||||||
|
commandLine.parseArgs(args);
|
||||||
|
} catch (picocli.CommandLine.ParameterException ex) {
|
||||||
|
throw new AssertionError(
|
||||||
|
"documented README invocation failed picocli parse: "
|
||||||
|
+ String.join(" ", args)
|
||||||
|
+ " -> "
|
||||||
|
+ ex.getMessage(),
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsCommandFailsFastOnQueueOverflow() {
|
||||||
|
// Client.Java-033 regression — the CLI's stream-alarms bounded queue
|
||||||
|
// used queue.offer(value) which silently dropped messages past
|
||||||
|
// capacity (1024). After the fix the CLI must surface the overflow
|
||||||
|
// as a non-zero exit (mirroring MxEventStream's fail-fast contract).
|
||||||
|
//
|
||||||
|
// The OverflowingFakeClient floods the gRPC observer with 2000
|
||||||
|
// messages synchronously, which exceeds the bounded 1024-element
|
||||||
|
// queue. The fix detects the failed offer, cancels the subscription,
|
||||||
|
// queues an overflow exception, and the drain loop surfaces it.
|
||||||
|
OverflowingFakeClientFactory factory = new OverflowingFakeClientFactory();
|
||||||
|
CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Flood");
|
||||||
|
|
||||||
|
assertFalse(run.exitCode() == 0,
|
||||||
|
"expected non-zero exit when the alarm queue overflows; got exit=" + run.exitCode()
|
||||||
|
+ " out=\n" + run.output() + "\nerr=\n" + run.errors());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void batchCommandExecutesVersionAndEmitsEorMarker() {
|
void batchCommandExecutesVersionAndEmitsEorMarker() {
|
||||||
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
|
CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");
|
||||||
@@ -235,6 +318,68 @@ final class MxGatewayCliTests {
|
|||||||
assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
|
assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces() {
|
||||||
|
// Client.Java-034 regression — a real shell-style tokenizer must not
|
||||||
|
// shred `"needs verification"` into two arguments. Drives
|
||||||
|
// acknowledge-alarm through batch and asserts the captured --comment
|
||||||
|
// is the un-quoted string with the embedded space preserved.
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"needs verification\" --operator op1\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
assertEquals("op1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
|
||||||
|
assertEquals(
|
||||||
|
"Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line =
|
||||||
|
"acknowledge-alarm --reference Tank01.Level.HiHi --comment 'needs verification' --operator op1\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line =
|
||||||
|
"acknowledge-alarm --reference Tank01.Level.HiHi --comment needs\\ verification\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandPreservesEmptyQuotedArgument() {
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"\"\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes() {
|
||||||
|
// `--comment "with \"inner\" quote"` should round-trip the inner
|
||||||
|
// double-quote into the comment string.
|
||||||
|
FakeClientFactory factory = new FakeClientFactory();
|
||||||
|
String line =
|
||||||
|
"acknowledge-alarm --reference Tank01.Level.HiHi --comment \"with \\\"inner\\\" quote\"\n";
|
||||||
|
CliRun run = executeBatch(factory, line);
|
||||||
|
|
||||||
|
assertEquals(0, run.exitCode());
|
||||||
|
assertEquals("with \"inner\" quote", factory.client.lastAcknowledgeAlarmRequest.getComment());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void batchCommandEmitsEorAfterFailedCommandAndContinues() {
|
void batchCommandEmitsEorAfterFailedCommandAndContinues() {
|
||||||
// An unknown subcommand causes a picocli parse error (non-zero exit).
|
// An unknown subcommand causes a picocli parse error (non-zero exit).
|
||||||
@@ -290,6 +435,77 @@ final class MxGatewayCliTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory whose fake client floods the {@code streamAlarms} observer with
|
||||||
|
* 2000 messages synchronously, exceeding the CLI's bounded 1024-element
|
||||||
|
* queue. Used by the Client.Java-033 fail-fast overflow regression.
|
||||||
|
*/
|
||||||
|
private static final class OverflowingFakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
|
||||||
|
return new OverflowingFakeClient(options.spec.commandLine().getOut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class OverflowingFakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
|
private final PrintWriter out;
|
||||||
|
|
||||||
|
OverflowingFakeClient(PrintWriter out) {
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrintWriter out() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OpenSessionReply openSession(OpenSessionRequest request) {
|
||||||
|
return OpenSessionReply.newBuilder().setSessionId("flood-session").setProtocolStatus(ok()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CloseSessionReply closeSession(CloseSessionRequest request) {
|
||||||
|
return CloseSessionReply.newBuilder()
|
||||||
|
.setSessionId(request.getSessionId())
|
||||||
|
.setFinalState(SessionState.SESSION_STATE_CLOSED)
|
||||||
|
.setProtocolStatus(ok())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MxGatewayAlarmFeedSubscription streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
|
||||||
|
// Synchronously push 2000 messages to overflow the CLI's bounded
|
||||||
|
// 1024-element queue. The CLI must surface the overflow rather
|
||||||
|
// than silently dropping the trailing ~976 messages.
|
||||||
|
for (int i = 0; i < 2000; i++) {
|
||||||
|
observer.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("Flood." + i)
|
||||||
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
observer.onCompleted();
|
||||||
|
return new MxGatewayAlarmFeedSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
|
||||||
private final PrintWriter out;
|
private final PrintWriter out;
|
||||||
private final FakeSession session = new FakeSession();
|
private final FakeSession session = new FakeSession();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java-library'
|
id 'java-library'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
|
id 'maven-publish'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -30,6 +31,11 @@ sourceSets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
withSourcesJar()
|
||||||
|
withJavadocJar()
|
||||||
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
protoc {
|
protoc {
|
||||||
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters and shape options for {@link GalaxyRepositoryClient#browse(BrowseChildrenOptions)}.
|
||||||
|
* Mirror of the existing DiscoverHierarchy options for the lazy-browse path.
|
||||||
|
*
|
||||||
|
* <p>All filter fields are AND-combined server-side. Empty / unset fields disable
|
||||||
|
* that filter. The {@code includeAttributes} tri-state uses {@code null} to mean
|
||||||
|
* "let the server use its default"; non-{@code null} forwards the explicit flag.
|
||||||
|
*/
|
||||||
|
public final class BrowseChildrenOptions {
|
||||||
|
private final List<Integer> categoryIds;
|
||||||
|
private final List<String> templateChainContains;
|
||||||
|
private final String tagNameGlob;
|
||||||
|
private final Boolean includeAttributes;
|
||||||
|
private final boolean alarmBearingOnly;
|
||||||
|
private final boolean historizedOnly;
|
||||||
|
|
||||||
|
private BrowseChildrenOptions(Builder b) {
|
||||||
|
this.categoryIds = List.copyOf(b.categoryIds);
|
||||||
|
this.templateChainContains = List.copyOf(b.templateChainContains);
|
||||||
|
this.tagNameGlob = b.tagNameGlob;
|
||||||
|
this.includeAttributes = b.includeAttributes;
|
||||||
|
this.alarmBearingOnly = b.alarmBearingOnly;
|
||||||
|
this.historizedOnly = b.historizedOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return immutable list of category IDs to include; empty disables this filter. */
|
||||||
|
public List<Integer> getCategoryIds() { return categoryIds; }
|
||||||
|
|
||||||
|
/** @return immutable list of template names that must appear in each child's template chain. */
|
||||||
|
public List<String> getTemplateChainContains() { return templateChainContains; }
|
||||||
|
|
||||||
|
/** @return SQL-LIKE-style glob applied to {@code tag_name}; empty disables. */
|
||||||
|
public String getTagNameGlob() { return tagNameGlob; }
|
||||||
|
|
||||||
|
/** @return tri-state override for {@code include_attributes}; {@code null} keeps the server default. */
|
||||||
|
public Boolean getIncludeAttributes() { return includeAttributes; }
|
||||||
|
|
||||||
|
/** @return restrict to alarm-bearing objects. */
|
||||||
|
public boolean isAlarmBearingOnly() { return alarmBearingOnly; }
|
||||||
|
|
||||||
|
/** @return restrict to objects with at least one historized attribute. */
|
||||||
|
public boolean isHistorizedOnly() { return historizedOnly; }
|
||||||
|
|
||||||
|
/** @return a fresh builder. */
|
||||||
|
public static Builder builder() { return new Builder(); }
|
||||||
|
|
||||||
|
/** @return options with every filter disabled and {@code includeAttributes} unset. */
|
||||||
|
public static BrowseChildrenOptions empty() { return builder().build(); }
|
||||||
|
|
||||||
|
/** Fluent builder for {@link BrowseChildrenOptions}. */
|
||||||
|
public static final class Builder {
|
||||||
|
private List<Integer> categoryIds = Collections.emptyList();
|
||||||
|
private List<String> templateChainContains = Collections.emptyList();
|
||||||
|
private String tagNameGlob = "";
|
||||||
|
private Boolean includeAttributes = null;
|
||||||
|
private boolean alarmBearingOnly = false;
|
||||||
|
private boolean historizedOnly = false;
|
||||||
|
|
||||||
|
/** Sets the category-id filter. */
|
||||||
|
public Builder categoryIds(List<Integer> v) {
|
||||||
|
this.categoryIds = v == null ? Collections.emptyList() : v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the template-chain-contains filter. */
|
||||||
|
public Builder templateChainContains(List<String> v) {
|
||||||
|
this.templateChainContains = v == null ? Collections.emptyList() : v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the tag-name glob. */
|
||||||
|
public Builder tagNameGlob(String v) {
|
||||||
|
this.tagNameGlob = v == null ? "" : v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the tri-state {@code includeAttributes} override; {@code null} keeps the server default. */
|
||||||
|
public Builder includeAttributes(Boolean v) {
|
||||||
|
this.includeAttributes = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the alarm-bearing-only filter. */
|
||||||
|
public Builder alarmBearingOnly(boolean v) {
|
||||||
|
this.alarmBearingOnly = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the historized-only filter. */
|
||||||
|
public Builder historizedOnly(boolean v) {
|
||||||
|
this.historizedOnly = v;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the immutable options. */
|
||||||
|
public BrowseChildrenOptions build() {
|
||||||
|
return new BrowseChildrenOptions(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-52
@@ -2,64 +2,19 @@ package com.zb.mom.ww.mxgateway.client;
|
|||||||
|
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
import io.grpc.stub.ClientCallStreamObserver;
|
|
||||||
import io.grpc.stub.ClientResponseObserver;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
|
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
|
||||||
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
|
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
|
||||||
* deploy-event stream.
|
* deploy-event stream.
|
||||||
|
*
|
||||||
|
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||||
|
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||||
*/
|
*/
|
||||||
public final class DeployEventSubscription implements AutoCloseable {
|
public final class DeployEventSubscription
|
||||||
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
|
extends MxGatewayStreamSubscription<WatchDeployEventsRequest, DeployEvent> {
|
||||||
new AtomicReference<>();
|
public DeployEventSubscription() {
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
super("client cancelled deploy event stream");
|
||||||
|
|
||||||
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
|
|
||||||
return new ClientResponseObserver<>() {
|
|
||||||
@Override
|
|
||||||
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
|
|
||||||
requestStream.set(stream);
|
|
||||||
if (cancelled.get()) {
|
|
||||||
stream.cancel("client cancelled deploy event stream", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(DeployEvent value) {
|
|
||||||
observer.onNext(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable error) {
|
|
||||||
observer.onError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCompleted() {
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
|
||||||
* started; cancellation is recorded and applied as soon as the stream
|
|
||||||
* attaches.
|
|
||||||
*/
|
|
||||||
public void cancel() {
|
|
||||||
cancelled.set(true);
|
|
||||||
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream.get();
|
|
||||||
if (stream != null) {
|
|
||||||
stream.cancel("client cancelled deploy event stream", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
cancel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
@@ -4,6 +4,8 @@ import com.google.common.util.concurrent.FutureCallback;
|
|||||||
import com.google.common.util.concurrent.Futures;
|
import com.google.common.util.concurrent.Futures;
|
||||||
import com.google.common.util.concurrent.MoreExecutors;
|
import com.google.common.util.concurrent.MoreExecutors;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||||
@@ -37,6 +39,7 @@ import javax.net.ssl.SSLException;
|
|||||||
*/
|
*/
|
||||||
public final class GalaxyRepositoryClient implements AutoCloseable {
|
public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||||
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
||||||
|
private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
|
||||||
|
|
||||||
private final ManagedChannel ownedChannel;
|
private final ManagedChannel ownedChannel;
|
||||||
private final MxGatewayClientOptions options;
|
private final MxGatewayClientOptions options;
|
||||||
@@ -213,6 +216,98 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
|||||||
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
|
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy.
|
||||||
|
* Each returned {@link LazyBrowseNode} can be expanded on demand via
|
||||||
|
* {@link LazyBrowseNode#expand()} to load its direct children.
|
||||||
|
*
|
||||||
|
* @return the root nodes (no parent selector) with default options
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public List<LazyBrowseNode> browse() {
|
||||||
|
return browse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-browse entry point with caller-supplied filters / shape.
|
||||||
|
*
|
||||||
|
* @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()}
|
||||||
|
* @return the root nodes matching the options
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public List<LazyBrowseNode> browse(BrowseChildrenOptions options) {
|
||||||
|
BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options;
|
||||||
|
return browseChildrenInner(null, effective);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a single {@code BrowseChildren} RPC and returns the raw reply.
|
||||||
|
* Callers wanting full control over pagination can drive the loop themselves.
|
||||||
|
*
|
||||||
|
* @param request the request to send
|
||||||
|
* @return the reply
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) {
|
||||||
|
try {
|
||||||
|
return rawBlockingStub().browseChildren(request);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
if (error instanceof MxGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw MxGatewayErrors.fromGrpc("galaxy browse children", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the BrowseChildren paging loop for a single parent (or roots when
|
||||||
|
* {@code parentGobjectId} is {@code null}). Detects repeated page tokens to
|
||||||
|
* avoid infinite loops on a buggy server.
|
||||||
|
*/
|
||||||
|
List<LazyBrowseNode> browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) {
|
||||||
|
java.util.ArrayList<LazyBrowseNode> nodes = new java.util.ArrayList<>();
|
||||||
|
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
|
||||||
|
String pageToken = "";
|
||||||
|
while (true) {
|
||||||
|
BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
|
||||||
|
.setPageSize(BROWSE_CHILDREN_PAGE_SIZE)
|
||||||
|
.setPageToken(pageToken)
|
||||||
|
.setAlarmBearingOnly(options.isAlarmBearingOnly())
|
||||||
|
.setHistorizedOnly(options.isHistorizedOnly());
|
||||||
|
if (parentGobjectId != null) {
|
||||||
|
builder.setParentGobjectId(parentGobjectId.intValue());
|
||||||
|
}
|
||||||
|
if (!options.getCategoryIds().isEmpty()) {
|
||||||
|
builder.addAllCategoryIds(options.getCategoryIds());
|
||||||
|
}
|
||||||
|
if (!options.getTemplateChainContains().isEmpty()) {
|
||||||
|
builder.addAllTemplateChainContains(options.getTemplateChainContains());
|
||||||
|
}
|
||||||
|
if (!options.getTagNameGlob().isEmpty()) {
|
||||||
|
builder.setTagNameGlob(options.getTagNameGlob());
|
||||||
|
}
|
||||||
|
if (options.getIncludeAttributes() != null) {
|
||||||
|
builder.setIncludeAttributes(options.getIncludeAttributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowseChildrenReply reply = browseChildrenRaw(builder.build());
|
||||||
|
|
||||||
|
for (int i = 0; i < reply.getChildrenCount(); i++) {
|
||||||
|
boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
|
||||||
|
nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = reply.getNextPageToken();
|
||||||
|
if (pageToken == null || pageToken.isEmpty()) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
if (!seenPageTokens.add(pageToken)) {
|
||||||
|
throw new MxGatewayException(
|
||||||
|
"galaxy browse children returned repeated page token: " + pageToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to {@code WatchDeployEvents} via the async stub and consumes
|
* Subscribes to {@code WatchDeployEvents} via the async stub and consumes
|
||||||
* results through a blocking iterator. Closing the returned stream cancels
|
* results through a blocking iterator. Closing the returned stream cancels
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One node in a lazy-loaded Galaxy browse tree. Holds the underlying
|
||||||
|
* {@link GalaxyObject} and exposes {@link #expand()} to fetch its direct
|
||||||
|
* children on demand. Expansion is one-shot: a second call is a no-op.
|
||||||
|
* Pagination of large sibling sets is handled internally by the client.
|
||||||
|
*/
|
||||||
|
public final class LazyBrowseNode {
|
||||||
|
private final GalaxyRepositoryClient client;
|
||||||
|
private final GalaxyObject object;
|
||||||
|
private final boolean hasChildrenHint;
|
||||||
|
private final BrowseChildrenOptions options;
|
||||||
|
|
||||||
|
// expandLock gates the start of a new expand AND the publish of the in-flight
|
||||||
|
// future. Readers (getChildren / isExpanded) use a separate read-write lock so
|
||||||
|
// they never block on the gRPC call.
|
||||||
|
private final Object expandLock = new Object();
|
||||||
|
private CompletableFuture<Void> inFlight;
|
||||||
|
|
||||||
|
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
|
||||||
|
private List<LazyBrowseNode> children = Collections.emptyList();
|
||||||
|
private boolean isExpanded;
|
||||||
|
|
||||||
|
LazyBrowseNode(
|
||||||
|
GalaxyRepositoryClient client,
|
||||||
|
GalaxyObject object,
|
||||||
|
boolean hasChildrenHint,
|
||||||
|
BrowseChildrenOptions options) {
|
||||||
|
this.client = client;
|
||||||
|
this.object = object;
|
||||||
|
this.hasChildrenHint = hasChildrenHint;
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the underlying Galaxy object proto for this node. */
|
||||||
|
public GalaxyObject getObject() {
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return {@code true} when the server reports this node has at least one matching descendant. */
|
||||||
|
public boolean hasChildrenHint() {
|
||||||
|
return hasChildrenHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
|
||||||
|
public List<LazyBrowseNode> getChildren() {
|
||||||
|
readWriteLock.readLock().lock();
|
||||||
|
try {
|
||||||
|
return List.copyOf(children);
|
||||||
|
} finally {
|
||||||
|
readWriteLock.readLock().unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return {@code true} after the first {@link #expand()} call completes. */
|
||||||
|
public boolean isExpanded() {
|
||||||
|
readWriteLock.readLock().lock();
|
||||||
|
try {
|
||||||
|
return isExpanded;
|
||||||
|
} finally {
|
||||||
|
readWriteLock.readLock().unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches direct children from the gateway and populates {@link #getChildren()}.
|
||||||
|
* Idempotent: subsequent calls are no-ops and do not issue a second RPC.
|
||||||
|
*
|
||||||
|
* <p>Concurrent callers coalesce onto a single in-flight RPC: the first caller
|
||||||
|
* (the "leader") issues the gRPC call, while any other thread that calls
|
||||||
|
* {@code expand()} during that window blocks on the leader's future and sees
|
||||||
|
* the same result (or the same exception). On failure the in-flight slot is
|
||||||
|
* cleared so a subsequent call can retry.
|
||||||
|
*
|
||||||
|
* <p>Readers ({@link #getChildren()} / {@link #isExpanded()}) take a separate
|
||||||
|
* read lock and are never blocked for the duration of the RPC.
|
||||||
|
*
|
||||||
|
* @throws MxGatewayException on transport or protocol failure
|
||||||
|
*/
|
||||||
|
public void expand() {
|
||||||
|
if (isExpanded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompletableFuture<Void> future;
|
||||||
|
boolean iAmTheLeader;
|
||||||
|
synchronized (expandLock) {
|
||||||
|
if (isExpanded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inFlight != null) {
|
||||||
|
future = inFlight;
|
||||||
|
iAmTheLeader = false;
|
||||||
|
} else {
|
||||||
|
future = new CompletableFuture<>();
|
||||||
|
inFlight = future;
|
||||||
|
iAmTheLeader = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iAmTheLeader) {
|
||||||
|
try {
|
||||||
|
List<LazyBrowseNode> loaded =
|
||||||
|
client.browseChildrenInner(object.getGobjectId(), options);
|
||||||
|
readWriteLock.writeLock().lock();
|
||||||
|
try {
|
||||||
|
this.children = loaded;
|
||||||
|
this.isExpanded = true;
|
||||||
|
} finally {
|
||||||
|
readWriteLock.writeLock().unlock();
|
||||||
|
}
|
||||||
|
synchronized (expandLock) {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
future.complete(null);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
synchronized (expandLock) {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
future.completeExceptionally(ex);
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
future.get();
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new MxGatewayException("Interrupted waiting for browse-children expand.", ie);
|
||||||
|
} catch (ExecutionException ee) {
|
||||||
|
Throwable cause = ee.getCause();
|
||||||
|
if (cause instanceof MxGatewayException me) {
|
||||||
|
throw me;
|
||||||
|
}
|
||||||
|
if (cause instanceof RuntimeException re) {
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
|
throw new MxGatewayException("BrowseChildren expand failed.", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-51
@@ -1,10 +1,6 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
import io.grpc.stub.ClientCallStreamObserver;
|
|
||||||
import io.grpc.stub.ClientResponseObserver;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||||
|
|
||||||
@@ -15,53 +11,13 @@ import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
|||||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||||
* try-with-resources blocks.
|
* try-with-resources blocks.
|
||||||
|
*
|
||||||
|
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||||
|
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||||
*/
|
*/
|
||||||
public final class MxGatewayActiveAlarmsSubscription implements AutoCloseable {
|
public final class MxGatewayActiveAlarmsSubscription
|
||||||
private final AtomicReference<ClientCallStreamObserver<QueryActiveAlarmsRequest>> requestStream = new AtomicReference<>();
|
extends MxGatewayStreamSubscription<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> {
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
public MxGatewayActiveAlarmsSubscription() {
|
||||||
|
super("client cancelled active-alarms query");
|
||||||
ClientResponseObserver<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> wrap(StreamObserver<ActiveAlarmSnapshot> observer) {
|
|
||||||
return new ClientResponseObserver<>() {
|
|
||||||
@Override
|
|
||||||
public void beforeStart(ClientCallStreamObserver<QueryActiveAlarmsRequest> stream) {
|
|
||||||
requestStream.set(stream);
|
|
||||||
if (cancelled.get()) {
|
|
||||||
stream.cancel("client cancelled active-alarms query", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(ActiveAlarmSnapshot value) {
|
|
||||||
observer.onNext(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable error) {
|
|
||||||
observer.onError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCompleted() {
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
|
||||||
* started; cancellation is recorded and applied as soon as the stream
|
|
||||||
* attaches.
|
|
||||||
*/
|
|
||||||
public void cancel() {
|
|
||||||
cancelled.set(true);
|
|
||||||
ClientCallStreamObserver<QueryActiveAlarmsRequest> stream = requestStream.get();
|
|
||||||
if (stream != null) {
|
|
||||||
stream.cancel("client cancelled active-alarms query", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
cancel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-51
@@ -1,10 +1,6 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
import io.grpc.stub.ClientCallStreamObserver;
|
|
||||||
import io.grpc.stub.ClientResponseObserver;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
|
|
||||||
@@ -15,53 +11,13 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
|||||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||||
* try-with-resources blocks.
|
* try-with-resources blocks.
|
||||||
|
*
|
||||||
|
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||||
|
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||||
*/
|
*/
|
||||||
public final class MxGatewayAlarmFeedSubscription implements AutoCloseable {
|
public final class MxGatewayAlarmFeedSubscription
|
||||||
private final AtomicReference<ClientCallStreamObserver<StreamAlarmsRequest>> requestStream = new AtomicReference<>();
|
extends MxGatewayStreamSubscription<StreamAlarmsRequest, AlarmFeedMessage> {
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
public MxGatewayAlarmFeedSubscription() {
|
||||||
|
super("client cancelled alarm feed");
|
||||||
ClientResponseObserver<StreamAlarmsRequest, AlarmFeedMessage> wrap(StreamObserver<AlarmFeedMessage> observer) {
|
|
||||||
return new ClientResponseObserver<>() {
|
|
||||||
@Override
|
|
||||||
public void beforeStart(ClientCallStreamObserver<StreamAlarmsRequest> stream) {
|
|
||||||
requestStream.set(stream);
|
|
||||||
if (cancelled.get()) {
|
|
||||||
stream.cancel("client cancelled alarm feed", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(AlarmFeedMessage value) {
|
|
||||||
observer.onNext(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable error) {
|
|
||||||
observer.onError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCompleted() {
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
|
||||||
* started; cancellation is recorded and applied as soon as the stream
|
|
||||||
* attaches.
|
|
||||||
*/
|
|
||||||
public void cancel() {
|
|
||||||
cancelled.set(true);
|
|
||||||
ClientCallStreamObserver<StreamAlarmsRequest> stream = requestStream.get();
|
|
||||||
if (stream != null) {
|
|
||||||
stream.cancel("client cancelled alarm feed", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
cancel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -384,6 +384,15 @@ public final class MxGatewayClient implements AutoCloseable {
|
|||||||
} catch (SSLException error) {
|
} catch (SSLException error) {
|
||||||
throw new MxGatewayException("failed to configure gateway TLS", error);
|
throw new MxGatewayException("failed to configure gateway TLS", error);
|
||||||
}
|
}
|
||||||
|
} else if (!options.requireCertificateValidation()) {
|
||||||
|
try {
|
||||||
|
builder.sslContext(GrpcSslContexts.forClient()
|
||||||
|
.trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util
|
||||||
|
.InsecureTrustManagerFactory.INSTANCE)
|
||||||
|
.build());
|
||||||
|
} catch (SSLException error) {
|
||||||
|
throw new MxGatewayException("failed to configure lenient gateway TLS", error);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
builder.useTransportSecurity();
|
builder.useTransportSecurity();
|
||||||
}
|
}
|
||||||
@@ -393,6 +402,19 @@ public final class MxGatewayClient implements AutoCloseable {
|
|||||||
return builder.build();
|
return builder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-visible test seam — creates a raw {@link ManagedChannel} from the
|
||||||
|
* given options without attaching auth interceptors. Used by TLS fixture
|
||||||
|
* tests to verify channel construction behaviour without a full
|
||||||
|
* {@link MxGatewayClient} wrapper.
|
||||||
|
*
|
||||||
|
* @param options the client options
|
||||||
|
* @return a new {@link ManagedChannel}
|
||||||
|
*/
|
||||||
|
static ManagedChannel createChannelForTests(MxGatewayClientOptions options) {
|
||||||
|
return createChannel(options);
|
||||||
|
}
|
||||||
|
|
||||||
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
|
||||||
if (options.callTimeout().isNegative()) {
|
if (options.callTimeout().isNegative()) {
|
||||||
return stub;
|
return stub;
|
||||||
|
|||||||
+32
@@ -20,6 +20,7 @@ public final class MxGatewayClientOptions {
|
|||||||
private final String apiKey;
|
private final String apiKey;
|
||||||
private final boolean plaintext;
|
private final boolean plaintext;
|
||||||
private final Path caCertificatePath;
|
private final Path caCertificatePath;
|
||||||
|
private final boolean requireCertificateValidation;
|
||||||
private final String serverNameOverride;
|
private final String serverNameOverride;
|
||||||
private final Duration connectTimeout;
|
private final Duration connectTimeout;
|
||||||
private final Duration callTimeout;
|
private final Duration callTimeout;
|
||||||
@@ -31,6 +32,7 @@ public final class MxGatewayClientOptions {
|
|||||||
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
apiKey = builder.apiKey == null ? "" : builder.apiKey;
|
||||||
plaintext = builder.plaintext;
|
plaintext = builder.plaintext;
|
||||||
caCertificatePath = builder.caCertificatePath;
|
caCertificatePath = builder.caCertificatePath;
|
||||||
|
requireCertificateValidation = builder.requireCertificateValidation;
|
||||||
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
serverNameOverride = builder.serverNameOverride == null ? "" : builder.serverNameOverride;
|
||||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||||
@@ -95,6 +97,18 @@ public final class MxGatewayClientOptions {
|
|||||||
return caCertificatePath;
|
return caCertificatePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether TLS certificate verification is required even when no CA is pinned.
|
||||||
|
* When {@code false} (default), the gateway's self-signed certificate is accepted
|
||||||
|
* without verification. When {@code true}, the OS trust store is used.
|
||||||
|
* Pinning a CA via {@link #caCertificatePath()} always verifies regardless of this flag.
|
||||||
|
*
|
||||||
|
* @return {@code true} if strict certificate verification is required
|
||||||
|
*/
|
||||||
|
public boolean requireCertificateValidation() {
|
||||||
|
return requireCertificateValidation;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the TLS server-name override, or an empty string when none was supplied.
|
* Returns the TLS server-name override, or an empty string when none was supplied.
|
||||||
*
|
*
|
||||||
@@ -148,6 +162,8 @@ public final class MxGatewayClientOptions {
|
|||||||
+ plaintext
|
+ plaintext
|
||||||
+ ", caCertificatePath="
|
+ ", caCertificatePath="
|
||||||
+ caCertificatePath
|
+ caCertificatePath
|
||||||
|
+ ", requireCertificateValidation="
|
||||||
|
+ requireCertificateValidation
|
||||||
+ ", serverNameOverride='"
|
+ ", serverNameOverride='"
|
||||||
+ serverNameOverride
|
+ serverNameOverride
|
||||||
+ '\''
|
+ '\''
|
||||||
@@ -177,6 +193,7 @@ public final class MxGatewayClientOptions {
|
|||||||
private String apiKey;
|
private String apiKey;
|
||||||
private boolean plaintext;
|
private boolean plaintext;
|
||||||
private Path caCertificatePath;
|
private Path caCertificatePath;
|
||||||
|
private boolean requireCertificateValidation;
|
||||||
private String serverNameOverride;
|
private String serverNameOverride;
|
||||||
private Duration connectTimeout;
|
private Duration connectTimeout;
|
||||||
private Duration callTimeout;
|
private Duration callTimeout;
|
||||||
@@ -230,6 +247,21 @@ public final class MxGatewayClientOptions {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When {@code true}, TLS connections without a pinned CA use the OS trust store
|
||||||
|
* and will reject the gateway's self-signed certificate. When {@code false}
|
||||||
|
* (default), the gateway certificate is accepted without verification —
|
||||||
|
* appropriate for this internal tool's auto-generated self-signed certificate.
|
||||||
|
* Pinning a CA via {@link #caCertificatePath(Path)} always verifies.
|
||||||
|
*
|
||||||
|
* @param value {@code true} to require certificate validation, {@code false} to accept any cert
|
||||||
|
* @return this builder
|
||||||
|
*/
|
||||||
|
public Builder requireCertificateValidation(boolean value) {
|
||||||
|
requireCertificateValidation = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides the TLS server name used during the handshake.
|
* Overrides the TLS server name used during the handshake.
|
||||||
*
|
*
|
||||||
|
|||||||
+7
-51
@@ -1,10 +1,6 @@
|
|||||||
package com.zb.mom.ww.mxgateway.client;
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
import io.grpc.stub.ClientCallStreamObserver;
|
|
||||||
import io.grpc.stub.ClientResponseObserver;
|
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||||
|
|
||||||
@@ -15,53 +11,13 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
|||||||
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
* {@link #cancel()} entry point that aborts the underlying gRPC call. The
|
||||||
* subscription also implements {@link AutoCloseable} so it can participate in
|
* subscription also implements {@link AutoCloseable} so it can participate in
|
||||||
* try-with-resources blocks.
|
* try-with-resources blocks.
|
||||||
|
*
|
||||||
|
* <p>All lifecycle / cancellation behaviour is inherited from
|
||||||
|
* {@link MxGatewayStreamSubscription} (Client.Java-036).
|
||||||
*/
|
*/
|
||||||
public final class MxGatewayEventSubscription implements AutoCloseable {
|
public final class MxGatewayEventSubscription
|
||||||
private final AtomicReference<ClientCallStreamObserver<StreamEventsRequest>> requestStream = new AtomicReference<>();
|
extends MxGatewayStreamSubscription<StreamEventsRequest, MxEvent> {
|
||||||
private final AtomicBoolean cancelled = new AtomicBoolean();
|
public MxGatewayEventSubscription() {
|
||||||
|
super("client cancelled event stream");
|
||||||
ClientResponseObserver<StreamEventsRequest, MxEvent> wrap(StreamObserver<MxEvent> observer) {
|
|
||||||
return new ClientResponseObserver<>() {
|
|
||||||
@Override
|
|
||||||
public void beforeStart(ClientCallStreamObserver<StreamEventsRequest> stream) {
|
|
||||||
requestStream.set(stream);
|
|
||||||
if (cancelled.get()) {
|
|
||||||
stream.cancel("client cancelled event stream", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(MxEvent value) {
|
|
||||||
observer.onNext(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable error) {
|
|
||||||
observer.onError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCompleted() {
|
|
||||||
observer.onCompleted();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
|
||||||
* started; cancellation is recorded and applied as soon as the stream
|
|
||||||
* attaches.
|
|
||||||
*/
|
|
||||||
public void cancel() {
|
|
||||||
cancelled.set(true);
|
|
||||||
ClientCallStreamObserver<StreamEventsRequest> stream = requestStream.get();
|
|
||||||
if (stream != null) {
|
|
||||||
stream.cancel("client cancelled event stream", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
cancel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared base for the cancellable subscription handles returned by the
|
||||||
|
* async-style server-streaming RPCs ({@code streamEvents}, {@code streamAlarms},
|
||||||
|
* {@code queryActiveAlarms}, {@code watchDeployEvents}).
|
||||||
|
*
|
||||||
|
* <p>All four subscription classes share the same lifecycle and cancellation
|
||||||
|
* contract:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #wrap(StreamObserver)} returns a {@link ClientResponseObserver}
|
||||||
|
* that captures the underlying {@link ClientCallStreamObserver} in
|
||||||
|
* {@code beforeStart}. If {@link #cancel()} was called before the gRPC
|
||||||
|
* call attached, the stream is cancelled eagerly inside
|
||||||
|
* {@code beforeStart} (the Client.Java-014 close-before-beforeStart
|
||||||
|
* fix).</li>
|
||||||
|
* <li>{@link #cancel()} is idempotent. It records the cancellation flag and
|
||||||
|
* forwards {@code cancel(message, cause)} to the underlying stream when
|
||||||
|
* one is attached; otherwise the flag is checked in {@code beforeStart}
|
||||||
|
* once the stream attaches.</li>
|
||||||
|
* <li>{@link #close()} delegates to {@link #cancel()} so the handle can be
|
||||||
|
* used with try-with-resources.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Subclasses supply only the cancel-message string used by {@code cancel()}.
|
||||||
|
* Refactor introduced for Client.Java-036 — the four prior subscription
|
||||||
|
* classes were structural near-clones (~60 lines each).
|
||||||
|
*/
|
||||||
|
abstract class MxGatewayStreamSubscription<TRequest, TResponse> implements AutoCloseable {
|
||||||
|
private final AtomicReference<ClientCallStreamObserver<TRequest>> requestStream = new AtomicReference<>();
|
||||||
|
private final AtomicBoolean cancelled = new AtomicBoolean();
|
||||||
|
private final String cancelMessage;
|
||||||
|
|
||||||
|
MxGatewayStreamSubscription(String cancelMessage) {
|
||||||
|
this.cancelMessage = cancelMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ClientResponseObserver<TRequest, TResponse> wrap(StreamObserver<TResponse> observer) {
|
||||||
|
return new ClientResponseObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void beforeStart(ClientCallStreamObserver<TRequest> stream) {
|
||||||
|
requestStream.set(stream);
|
||||||
|
if (cancelled.get()) {
|
||||||
|
stream.cancel(cancelMessage, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(TResponse value) {
|
||||||
|
observer.onNext(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
observer.onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
observer.onCompleted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the underlying gRPC call. Safe to invoke before the call has
|
||||||
|
* started; cancellation is recorded and applied as soon as the stream
|
||||||
|
* attaches.
|
||||||
|
*/
|
||||||
|
public final void cancel() {
|
||||||
|
cancelled.set(true);
|
||||||
|
ClientCallStreamObserver<TRequest> stream = requestStream.get();
|
||||||
|
if (stream != null) {
|
||||||
|
stream.cancel(cancelMessage, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final void close() {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
+321
@@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
import com.google.protobuf.Timestamp;
|
import com.google.protobuf.Timestamp;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
import galaxy_repository.v1.GalaxyRepositoryGrpc;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
|
||||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||||
@@ -24,6 +26,7 @@ import io.grpc.Server;
|
|||||||
import io.grpc.ServerCall;
|
import io.grpc.ServerCall;
|
||||||
import io.grpc.ServerCallHandler;
|
import io.grpc.ServerCallHandler;
|
||||||
import io.grpc.ServerInterceptor;
|
import io.grpc.ServerInterceptor;
|
||||||
|
import io.grpc.Status;
|
||||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||||
import io.grpc.inprocess.InProcessServerBuilder;
|
import io.grpc.inprocess.InProcessServerBuilder;
|
||||||
import io.grpc.stub.ClientCallStreamObserver;
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
@@ -31,11 +34,20 @@ import io.grpc.stub.ClientResponseObserver;
|
|||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Queue;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -196,6 +208,27 @@ final class GalaxyRepositoryClientTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseChildrenRejectsRepeatedPageToken() throws Exception {
|
||||||
|
// Queue the same BrowseChildrenReply twice with a non-empty NextPageToken.
|
||||||
|
// The client will request a second page and detect that the token repeats.
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
BrowseChildrenReply repeatedReply = browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
1L,
|
||||||
|
"1:abc:1");
|
||||||
|
service.replies.add(repeatedReply);
|
||||||
|
service.replies.add(repeatedReply);
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
MxGatewayException error = assertThrows(MxGatewayException.class, client::browse);
|
||||||
|
|
||||||
|
assertTrue(error.getMessage().contains("repeated page token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
||||||
DeployEvent first = DeployEvent.newBuilder()
|
DeployEvent first = DeployEvent.newBuilder()
|
||||||
@@ -306,6 +339,294 @@ final class GalaxyRepositoryClientTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseNoParentReturnsRoots() throws Exception {
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(1, "Plant", true), obj(2, "Other", false)),
|
||||||
|
List.of(true, false),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
|
||||||
|
assertEquals(2, roots.size());
|
||||||
|
assertEquals("Plant", roots.get(0).getObject().getTagName());
|
||||||
|
assertTrue(roots.get(0).hasChildrenHint());
|
||||||
|
assertFalse(roots.get(0).isExpanded());
|
||||||
|
assertEquals("Other", roots.get(1).getObject().getTagName());
|
||||||
|
assertFalse(roots.get(1).hasChildrenHint());
|
||||||
|
assertFalse(roots.get(1).isExpanded());
|
||||||
|
assertEquals(1, service.calls.size());
|
||||||
|
assertFalse(service.calls.get(0).hasParentGobjectId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseExpandPopulatesChildrenAndMarksExpanded() throws Exception {
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(10, "Line1", false)),
|
||||||
|
List.of(false),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
roots.get(0).expand();
|
||||||
|
|
||||||
|
assertTrue(roots.get(0).isExpanded());
|
||||||
|
assertEquals(1, roots.get(0).getChildren().size());
|
||||||
|
assertEquals("Line1", roots.get(0).getChildren().get(0).getObject().getTagName());
|
||||||
|
assertEquals(2, service.calls.size());
|
||||||
|
assertTrue(service.calls.get(1).hasParentGobjectId());
|
||||||
|
assertEquals(1, service.calls.get(1).getParentGobjectId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseExpandIdempotentNoSecondRpc() throws Exception {
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(10, "Line1", false)),
|
||||||
|
List.of(false),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
roots.get(0).expand();
|
||||||
|
roots.get(0).expand();
|
||||||
|
|
||||||
|
assertEquals(2, service.calls.size());
|
||||||
|
assertEquals(1, roots.get(0).getChildren().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseExpandUnknownParentThrowsGalaxyNotFound() throws Exception {
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
service.errors.add(Status.NOT_FOUND.withDescription("Parent not found").asRuntimeException());
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
|
||||||
|
MxGatewayException error = assertThrows(MxGatewayException.class, () -> roots.get(0).expand());
|
||||||
|
assertTrue(
|
||||||
|
error.getMessage().toLowerCase().contains("not found"),
|
||||||
|
"expected message to mention 'not found', got: " + error.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseExpandMultiPageGathersAllPages() throws Exception {
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
// Roots
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(7, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
// First child page with a next token
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(70, "ChildA", false), obj(71, "ChildB", false)),
|
||||||
|
List.of(false, false),
|
||||||
|
1L,
|
||||||
|
"7:abc:2"));
|
||||||
|
// Second child page closes the loop
|
||||||
|
service.replies.add(browseReply(
|
||||||
|
List.of(obj(72, "ChildC", false)),
|
||||||
|
List.of(false),
|
||||||
|
1L,
|
||||||
|
""));
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
roots.get(0).expand();
|
||||||
|
|
||||||
|
assertEquals(3, roots.get(0).getChildren().size());
|
||||||
|
assertEquals(3, service.calls.size());
|
||||||
|
assertEquals("7:abc:2", service.calls.get(2).getPageToken());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseExpandConcurrentCallersOnlyFireOneRpc() throws Exception {
|
||||||
|
// Verifies that concurrent expand() calls coalesce onto a single in-flight
|
||||||
|
// BrowseChildren RPC and that readers (isExpanded/getChildren) are not
|
||||||
|
// blocked for the full RPC duration.
|
||||||
|
BrowseChildrenReply rootsReply = browseReply(
|
||||||
|
List.of(obj(1, "Plant", true)),
|
||||||
|
List.of(true),
|
||||||
|
7L,
|
||||||
|
"");
|
||||||
|
BrowseChildrenReply childrenReply = browseReply(
|
||||||
|
List.of(obj(2, "Mixer_001", false)),
|
||||||
|
List.of(false),
|
||||||
|
7L,
|
||||||
|
"");
|
||||||
|
|
||||||
|
// Gate the child fetch behind a latch so multiple expanders can pile up.
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
AtomicInteger childCalls = new AtomicInteger();
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService() {
|
||||||
|
@Override
|
||||||
|
public void browseChildren(
|
||||||
|
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
|
||||||
|
calls.add(request);
|
||||||
|
BrowseChildrenReply reply;
|
||||||
|
if (!request.hasParentGobjectId()) {
|
||||||
|
reply = rootsReply;
|
||||||
|
} else {
|
||||||
|
// Block the leader until the followers have arrived.
|
||||||
|
try {
|
||||||
|
assertTrue(release.await(5, TimeUnit.SECONDS), "release latch never tripped");
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
responseObserver.onError(Status.CANCELLED.asRuntimeException());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
childCalls.incrementAndGet();
|
||||||
|
reply = childrenReply;
|
||||||
|
}
|
||||||
|
responseObserver.onNext(reply);
|
||||||
|
responseObserver.onCompleted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
List<LazyBrowseNode> roots = client.browse();
|
||||||
|
LazyBrowseNode root = roots.get(0);
|
||||||
|
|
||||||
|
int parallelism = 10;
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(parallelism);
|
||||||
|
try {
|
||||||
|
CountDownLatch ready = new CountDownLatch(parallelism);
|
||||||
|
List<Future<Void>> futures = new ArrayList<>();
|
||||||
|
for (int i = 0; i < parallelism; i++) {
|
||||||
|
futures.add(pool.submit(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
root.expand();
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Wait for all callers to be in flight, then release the leader.
|
||||||
|
assertTrue(ready.await(5, TimeUnit.SECONDS), "expander threads did not start");
|
||||||
|
// Readers must not be blocked by an in-flight expand; this should not deadlock
|
||||||
|
// and should return the pre-expand state.
|
||||||
|
assertFalse(root.isExpanded());
|
||||||
|
assertEquals(0, root.getChildren().size());
|
||||||
|
release.countDown();
|
||||||
|
|
||||||
|
for (Future<Void> f : futures) {
|
||||||
|
f.get(10, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pool.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(root.isExpanded());
|
||||||
|
assertEquals(1, root.getChildren().size());
|
||||||
|
// Exactly one expand RPC was issued even though many callers raced.
|
||||||
|
assertEquals(1, childCalls.get());
|
||||||
|
// 1 roots fetch + exactly 1 expand fetch.
|
||||||
|
assertEquals(2, service.calls.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void browseWithFilterForwardsToRequest() throws Exception {
|
||||||
|
BrowseChildrenService service = new BrowseChildrenService();
|
||||||
|
// Default reply is empty; only the request shape matters here.
|
||||||
|
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||||
|
GalaxyRepositoryClient client = g.client("")) {
|
||||||
|
client.browse(BrowseChildrenOptions.builder()
|
||||||
|
.tagNameGlob("Mixer*")
|
||||||
|
.alarmBearingOnly(true)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, service.calls.size());
|
||||||
|
BrowseChildrenRequest request = service.calls.get(0);
|
||||||
|
assertEquals("Mixer*", request.getTagNameGlob());
|
||||||
|
assertTrue(request.getAlarmBearingOnly());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GalaxyObject obj(int id, String tag, boolean isArea) {
|
||||||
|
return GalaxyObject.newBuilder()
|
||||||
|
.setGobjectId(id)
|
||||||
|
.setTagName(tag)
|
||||||
|
.setBrowseName(tag)
|
||||||
|
.setIsArea(isArea)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BrowseChildrenReply browseReply(
|
||||||
|
List<GalaxyObject> children,
|
||||||
|
List<Boolean> childHasChildren,
|
||||||
|
long cacheSequence,
|
||||||
|
String nextPageToken) {
|
||||||
|
BrowseChildrenReply.Builder b = BrowseChildrenReply.newBuilder()
|
||||||
|
.setTotalChildCount(children.size())
|
||||||
|
.setCacheSequence(cacheSequence)
|
||||||
|
.setNextPageToken(nextPageToken);
|
||||||
|
b.addAllChildren(children);
|
||||||
|
b.addAllChildHasChildren(childHasChildren);
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class BrowseChildrenService extends TestService {
|
||||||
|
final List<BrowseChildrenRequest> calls =
|
||||||
|
Collections.synchronizedList(new CopyOnWriteArrayList<>());
|
||||||
|
final Queue<BrowseChildrenReply> replies = new ArrayDeque<>();
|
||||||
|
final Queue<Throwable> errors = new ArrayDeque<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void browseChildren(
|
||||||
|
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
|
||||||
|
calls.add(request);
|
||||||
|
BrowseChildrenReply reply;
|
||||||
|
Throwable err;
|
||||||
|
synchronized (this) {
|
||||||
|
// Prefer queued replies first; once they're exhausted, fall through to any
|
||||||
|
// queued error. This matches the .NET fake's ordering used by parity tests.
|
||||||
|
reply = replies.poll();
|
||||||
|
err = reply == null ? errors.poll() : null;
|
||||||
|
}
|
||||||
|
if (err != null) {
|
||||||
|
responseObserver.onError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reply == null) {
|
||||||
|
reply = BrowseChildrenReply.getDefaultInstance();
|
||||||
|
}
|
||||||
|
responseObserver.onNext(reply);
|
||||||
|
responseObserver.onCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
|
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
|
||||||
@Override
|
@Override
|
||||||
public void testConnection(
|
public void testConnection(
|
||||||
|
|||||||
+98
@@ -27,7 +27,10 @@ import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
|
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||||
@@ -41,6 +44,7 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
|||||||
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||||
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -268,6 +272,100 @@ final class MxGatewayClientSessionTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages() throws Exception {
|
||||||
|
AtomicReference<StreamAlarmsRequest> streamRequest = new AtomicReference<>();
|
||||||
|
CountDownLatch serverCancelled = new CountDownLatch(1);
|
||||||
|
TestGatewayService service = new TestGatewayService() {
|
||||||
|
@Override
|
||||||
|
public void streamAlarms(
|
||||||
|
StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> responseObserver) {
|
||||||
|
streamRequest.set(request);
|
||||||
|
ServerCallStreamObserver<AlarmFeedMessage> server =
|
||||||
|
(ServerCallStreamObserver<AlarmFeedMessage>) responseObserver;
|
||||||
|
server.setOnCancelHandler(serverCancelled::countDown);
|
||||||
|
// Active-alarm snapshot, snapshot-complete sentinel, then a
|
||||||
|
// transition — mirrors the shape of a real alarm feed open.
|
||||||
|
server.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
server.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
|
||||||
|
server.onNext(AlarmFeedMessage.newBuilder()
|
||||||
|
.setTransition(OnAlarmTransitionEvent.newBuilder()
|
||||||
|
.setAlarmFullReference("Tank01.Level.HiHi")
|
||||||
|
.setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
|
||||||
|
.setSeverity(700))
|
||||||
|
.build());
|
||||||
|
// Note: we deliberately do NOT call onCompleted() so the call
|
||||||
|
// remains open for the cancellation assertion below.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||||
|
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||||
|
java.util.List<AlarmFeedMessage> received = new java.util.ArrayList<>();
|
||||||
|
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||||
|
CountDownLatch threeReceived = new CountDownLatch(3);
|
||||||
|
|
||||||
|
StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
|
||||||
|
.setAlarmFilterPrefix("Tank01")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
MxGatewayAlarmFeedSubscription subscription = client.streamAlarms(
|
||||||
|
request,
|
||||||
|
new StreamObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(AlarmFeedMessage value) {
|
||||||
|
received.add(value);
|
||||||
|
threeReceived.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable t) {
|
||||||
|
errorRef.set(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assertTrue(threeReceived.await(5, TimeUnit.SECONDS),
|
||||||
|
"expected three alarm feed messages within 5s");
|
||||||
|
|
||||||
|
// The request shape (filter prefix in particular) must reach the
|
||||||
|
// server — proves MxGatewayClient.streamAlarms calls the production
|
||||||
|
// subscription.wrap(observer) glue and not a CLI override.
|
||||||
|
assertNotNull(streamRequest.get());
|
||||||
|
assertEquals("Tank01", streamRequest.get().getAlarmFilterPrefix());
|
||||||
|
|
||||||
|
// Order and payload-case must be preserved (the wrapping observer
|
||||||
|
// is just a pass-through).
|
||||||
|
assertEquals(3, received.size());
|
||||||
|
assertEquals(AlarmFeedMessage.PayloadCase.ACTIVE_ALARM, received.get(0).getPayloadCase());
|
||||||
|
assertEquals(
|
||||||
|
"Tank01.Level.HiHi",
|
||||||
|
received.get(0).getActiveAlarm().getAlarmFullReference());
|
||||||
|
assertEquals(AlarmFeedMessage.PayloadCase.SNAPSHOT_COMPLETE, received.get(1).getPayloadCase());
|
||||||
|
assertEquals(AlarmFeedMessage.PayloadCase.TRANSITION, received.get(2).getPayloadCase());
|
||||||
|
assertEquals(
|
||||||
|
AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE,
|
||||||
|
received.get(2).getTransition().getTransitionKind());
|
||||||
|
|
||||||
|
// No error expected before cancellation — proves the wrapping
|
||||||
|
// observer forwarded only data, not a synthetic error.
|
||||||
|
assertNull(errorRef.get(), "no error expected before cancellation");
|
||||||
|
|
||||||
|
// Cancellation must propagate to the underlying gRPC call.
|
||||||
|
subscription.cancel();
|
||||||
|
assertTrue(serverCancelled.await(5, TimeUnit.SECONDS),
|
||||||
|
"server should observe RPC cancellation after subscription.cancel()");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void commandFailureKeepsRawReply() throws Exception {
|
void commandFailureKeepsRawReply() throws Exception {
|
||||||
TestGatewayService service = new TestGatewayService() {
|
TestGatewayService service = new TestGatewayService() {
|
||||||
|
|||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import io.grpc.ManagedChannel;
|
||||||
|
import io.grpc.Server;
|
||||||
|
import io.grpc.StatusRuntimeException;
|
||||||
|
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
|
||||||
|
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import javax.net.ssl.SSLException;
|
||||||
|
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the Java client connects to a Netty TLS server with a
|
||||||
|
* self-signed certificate when no CA is pinned (lenient default), and that
|
||||||
|
* setting {@code requireCertificateValidation(true)} causes a TLS failure.
|
||||||
|
*
|
||||||
|
* <p>A self-signed certificate is generated using {@code keytool} (always
|
||||||
|
* available in the JDK) to avoid dependencies on internal JDK APIs or
|
||||||
|
* BouncyCastle, and so the test works on all JDK versions used by the project.
|
||||||
|
*/
|
||||||
|
final class MxGatewayClientTlsTests {
|
||||||
|
|
||||||
|
private Server server;
|
||||||
|
private int port;
|
||||||
|
private File certPemFile;
|
||||||
|
private File keyPemFile;
|
||||||
|
private File keystoreFile;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void startTlsServer() throws Exception {
|
||||||
|
keystoreFile = File.createTempFile("gw-test-ks", ".p12");
|
||||||
|
certPemFile = File.createTempFile("gw-test-cert", ".pem");
|
||||||
|
keyPemFile = File.createTempFile("gw-test-key", ".pem");
|
||||||
|
|
||||||
|
// keytool refuses to write to a pre-existing (even empty) file; delete it first.
|
||||||
|
keystoreFile.delete();
|
||||||
|
|
||||||
|
// Use keytool to generate a self-signed PKCS12 keystore.
|
||||||
|
String keytool = ProcessHandle.current().info().command()
|
||||||
|
.map(cmd -> cmd.replace("java", "keytool"))
|
||||||
|
.orElse("keytool");
|
||||||
|
// Fall back to just "keytool" on PATH if the resolved path doesn't exist.
|
||||||
|
if (!new File(keytool).exists()) {
|
||||||
|
keytool = "keytool";
|
||||||
|
}
|
||||||
|
Process p = new ProcessBuilder(
|
||||||
|
keytool,
|
||||||
|
"-genkeypair",
|
||||||
|
"-alias", "server",
|
||||||
|
"-keyalg", "RSA",
|
||||||
|
"-keysize", "2048",
|
||||||
|
"-sigalg", "SHA256withRSA",
|
||||||
|
"-validity", "1",
|
||||||
|
"-dname", "CN=localhost",
|
||||||
|
"-storetype", "PKCS12",
|
||||||
|
"-storepass", "changeit",
|
||||||
|
"-keypass", "changeit",
|
||||||
|
"-keystore", keystoreFile.getAbsolutePath())
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start();
|
||||||
|
int exit = p.waitFor();
|
||||||
|
if (exit != 0) {
|
||||||
|
String out = new String(p.getInputStream().readAllBytes());
|
||||||
|
throw new IllegalStateException("keytool failed (exit " + exit + "): " + out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export cert and private key from the PKCS12 keystore to PEM files.
|
||||||
|
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||||
|
try (var is = Files.newInputStream(keystoreFile.toPath())) {
|
||||||
|
ks.load(is, "changeit".toCharArray());
|
||||||
|
}
|
||||||
|
X509Certificate cert = (X509Certificate) ks.getCertificate("server");
|
||||||
|
PrivateKey privateKey = (PrivateKey) ks.getKey("server", "changeit".toCharArray());
|
||||||
|
|
||||||
|
try (FileOutputStream out = new FileOutputStream(certPemFile)) {
|
||||||
|
out.write("-----BEGIN CERTIFICATE-----\n".getBytes());
|
||||||
|
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(cert.getEncoded()));
|
||||||
|
out.write("\n-----END CERTIFICATE-----\n".getBytes());
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(keyPemFile)) {
|
||||||
|
out.write("-----BEGIN PRIVATE KEY-----\n".getBytes());
|
||||||
|
out.write(Base64.getMimeEncoder(64, new byte[]{'\n'}).encode(privateKey.getEncoded()));
|
||||||
|
out.write("\n-----END PRIVATE KEY-----\n".getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
server = NettyServerBuilder
|
||||||
|
.forAddress(new InetSocketAddress("127.0.0.1", 0))
|
||||||
|
.sslContext(GrpcSslContexts.forServer(certPemFile, keyPemFile).build())
|
||||||
|
.addService(new MinimalGatewayService())
|
||||||
|
.build()
|
||||||
|
.start();
|
||||||
|
port = server.getPort();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stopTlsServer() throws InterruptedException {
|
||||||
|
if (server != null) {
|
||||||
|
server.shutdown();
|
||||||
|
server.awaitTermination(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
if (certPemFile != null) {
|
||||||
|
certPemFile.delete();
|
||||||
|
}
|
||||||
|
if (keyPemFile != null) {
|
||||||
|
keyPemFile.delete();
|
||||||
|
}
|
||||||
|
if (keystoreFile != null) {
|
||||||
|
keystoreFile.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void connectsToSelfSignedServer_WhenRequireCertificateValidationIsFalse() throws SSLException {
|
||||||
|
// Default options — requireCertificateValidation defaults to false.
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("127.0.0.1:" + port)
|
||||||
|
.apiKey("test-key")
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.callTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
|
||||||
|
try {
|
||||||
|
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
|
||||||
|
MxAccessGatewayGrpc.newBlockingStub(channel);
|
||||||
|
OpenSessionReply reply = stub.openSession(
|
||||||
|
OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName("tls-test")
|
||||||
|
.build());
|
||||||
|
assertTrue(reply.getProtocolStatus().getCode()
|
||||||
|
== ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK);
|
||||||
|
} finally {
|
||||||
|
channel.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failsToConnect_WhenRequireCertificateValidationIsTrue() throws SSLException {
|
||||||
|
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
|
||||||
|
.endpoint("127.0.0.1:" + port)
|
||||||
|
.apiKey("test-key")
|
||||||
|
.requireCertificateValidation(true)
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.callTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ManagedChannel channel = MxGatewayClient.createChannelForTests(options);
|
||||||
|
try {
|
||||||
|
MxAccessGatewayGrpc.MxAccessGatewayBlockingStub stub =
|
||||||
|
MxAccessGatewayGrpc.newBlockingStub(channel);
|
||||||
|
assertThrows(StatusRuntimeException.class, () ->
|
||||||
|
stub.openSession(OpenSessionRequest.newBuilder()
|
||||||
|
.setClientSessionName("tls-strict-test")
|
||||||
|
.build()));
|
||||||
|
} finally {
|
||||||
|
channel.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal gateway stub that succeeds any OpenSession call. */
|
||||||
|
private static final class MinimalGatewayService
|
||||||
|
extends MxAccessGatewayGrpc.MxAccessGatewayImplBase {
|
||||||
|
@Override
|
||||||
|
public void openSession(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
StreamObserver<OpenSessionReply> responseObserver) {
|
||||||
|
responseObserver.onNext(OpenSessionReply.newBuilder()
|
||||||
|
.setSessionId("tls-test-session")
|
||||||
|
.setProtocolStatus(ProtocolStatus.newBuilder()
|
||||||
|
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
|
||||||
|
.build())
|
||||||
|
.build());
|
||||||
|
responseObserver.onCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
package com.zb.mom.ww.mxgateway.client;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
|
||||||
|
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
|
||||||
|
import io.grpc.stub.ClientCallStreamObserver;
|
||||||
|
import io.grpc.stub.ClientResponseObserver;
|
||||||
|
import io.grpc.stub.StreamObserver;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
|
||||||
|
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle / cancellation contract tests applied uniformly to each of the
|
||||||
|
* four subscription classes that extend {@link MxGatewayStreamSubscription}.
|
||||||
|
*
|
||||||
|
* <p>Locks in the Client.Java-036 refactor: every subclass must exhibit the
|
||||||
|
* same behaviour for (a) cancel-before-beforeStart eagerly cancelling the
|
||||||
|
* stream once it attaches, (b) cancel-after-beforeStart forwarding directly
|
||||||
|
* to the stream, (c) the cancel message matching the subclass's documented
|
||||||
|
* value, (d) {@code close()} delegating to {@code cancel()}, and (e) the
|
||||||
|
* wrapping observer forwarding {@code onNext}/{@code onError}/{@code onCompleted}
|
||||||
|
* to the caller's observer.
|
||||||
|
*/
|
||||||
|
final class MxGatewayStreamSubscriptionContractTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_eventSubscription() {
|
||||||
|
runCancelBeforeBeforeStartTest(new MxGatewayEventSubscription(), "client cancelled event stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_alarmFeedSubscription() {
|
||||||
|
runCancelBeforeBeforeStartTest(
|
||||||
|
new MxGatewayAlarmFeedSubscription(), "client cancelled alarm feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_activeAlarmsSubscription() {
|
||||||
|
runCancelBeforeBeforeStartTest(
|
||||||
|
new MxGatewayActiveAlarmsSubscription(), "client cancelled active-alarms query");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelBeforeBeforeStartCancelsStreamWhenItAttaches_deployEventSubscription() {
|
||||||
|
runCancelBeforeBeforeStartTest(
|
||||||
|
new DeployEventSubscription(), "client cancelled deploy event stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelAfterBeforeStartForwardsToStream_eventSubscription() {
|
||||||
|
runCancelAfterBeforeStartTest(new MxGatewayEventSubscription(), "client cancelled event stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelAfterBeforeStartForwardsToStream_alarmFeedSubscription() {
|
||||||
|
runCancelAfterBeforeStartTest(
|
||||||
|
new MxGatewayAlarmFeedSubscription(), "client cancelled alarm feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelAfterBeforeStartForwardsToStream_activeAlarmsSubscription() {
|
||||||
|
runCancelAfterBeforeStartTest(
|
||||||
|
new MxGatewayActiveAlarmsSubscription(), "client cancelled active-alarms query");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelAfterBeforeStartForwardsToStream_deployEventSubscription() {
|
||||||
|
runCancelAfterBeforeStartTest(
|
||||||
|
new DeployEventSubscription(), "client cancelled deploy event stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeDelegatesToCancel_eventSubscription() {
|
||||||
|
runCloseDelegatesToCancelTest(new MxGatewayEventSubscription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeDelegatesToCancel_alarmFeedSubscription() {
|
||||||
|
runCloseDelegatesToCancelTest(new MxGatewayAlarmFeedSubscription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeDelegatesToCancel_activeAlarmsSubscription() {
|
||||||
|
runCloseDelegatesToCancelTest(new MxGatewayActiveAlarmsSubscription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closeDelegatesToCancel_deployEventSubscription() {
|
||||||
|
runCloseDelegatesToCancelTest(new DeployEventSubscription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrappedObserverForwardsOnNextOnErrorOnCompleted_eventSubscription() {
|
||||||
|
MxEvent event = MxEvent.newBuilder().setWorkerSequence(7L).build();
|
||||||
|
runForwardingTest(new MxGatewayEventSubscription(), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrappedObserverForwardsOnNextOnErrorOnCompleted_alarmFeedSubscription() {
|
||||||
|
AlarmFeedMessage msg = AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build();
|
||||||
|
runForwardingTest(new MxGatewayAlarmFeedSubscription(), msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrappedObserverForwardsOnNextOnErrorOnCompleted_activeAlarmsSubscription() {
|
||||||
|
ActiveAlarmSnapshot snap = ActiveAlarmSnapshot.newBuilder()
|
||||||
|
.setAlarmFullReference("ref")
|
||||||
|
.setSeverity(500)
|
||||||
|
.build();
|
||||||
|
runForwardingTest(new MxGatewayActiveAlarmsSubscription(), snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrappedObserverForwardsOnNextOnErrorOnCompleted_deployEventSubscription() {
|
||||||
|
DeployEvent ev = DeployEvent.newBuilder().setSequence(1L).build();
|
||||||
|
runForwardingTest(new DeployEventSubscription(), ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <Req, Resp> void runCancelBeforeBeforeStartTest(
|
||||||
|
MxGatewayStreamSubscription<Req, Resp> subscription, String expectedMessage) {
|
||||||
|
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(new NoopObserver<>());
|
||||||
|
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||||
|
|
||||||
|
subscription.cancel();
|
||||||
|
wrapped.beforeStart(stream);
|
||||||
|
|
||||||
|
assertTrue(stream.cancelled, "stream should have been cancelled by beforeStart after prior cancel()");
|
||||||
|
assertEquals(expectedMessage, stream.cancelMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <Req, Resp> void runCancelAfterBeforeStartTest(
|
||||||
|
MxGatewayStreamSubscription<Req, Resp> subscription, String expectedMessage) {
|
||||||
|
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(new NoopObserver<>());
|
||||||
|
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||||
|
|
||||||
|
wrapped.beforeStart(stream);
|
||||||
|
assertFalse(stream.cancelled, "stream should not be cancelled before cancel() is called");
|
||||||
|
subscription.cancel();
|
||||||
|
|
||||||
|
assertTrue(stream.cancelled, "stream should have been cancelled by direct cancel()");
|
||||||
|
assertEquals(expectedMessage, stream.cancelMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <Req, Resp> void runCloseDelegatesToCancelTest(
|
||||||
|
MxGatewayStreamSubscription<Req, Resp> subscription) {
|
||||||
|
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(new NoopObserver<>());
|
||||||
|
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||||
|
|
||||||
|
wrapped.beforeStart(stream);
|
||||||
|
subscription.close();
|
||||||
|
|
||||||
|
assertTrue(stream.cancelled, "close() should delegate to cancel()");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <Req, Resp> void runForwardingTest(
|
||||||
|
MxGatewayStreamSubscription<Req, Resp> subscription, Resp value) {
|
||||||
|
List<Resp> received = new ArrayList<>();
|
||||||
|
AtomicReference<Throwable> errorRef = new AtomicReference<>();
|
||||||
|
AtomicReference<Boolean> completed = new AtomicReference<>(false);
|
||||||
|
|
||||||
|
StreamObserver<Resp> caller = new StreamObserver<>() {
|
||||||
|
@Override
|
||||||
|
public void onNext(Resp v) {
|
||||||
|
received.add(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable t) {
|
||||||
|
errorRef.set(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
completed.set(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ClientResponseObserver<Req, Resp> wrapped = subscription.wrap(caller);
|
||||||
|
RecordingClientCallStreamObserver<Req> stream = new RecordingClientCallStreamObserver<>();
|
||||||
|
wrapped.beforeStart(stream);
|
||||||
|
|
||||||
|
wrapped.onNext(value);
|
||||||
|
IllegalStateException boom = new IllegalStateException("boom");
|
||||||
|
wrapped.onError(boom);
|
||||||
|
wrapped.onCompleted();
|
||||||
|
|
||||||
|
assertEquals(1, received.size());
|
||||||
|
assertEquals(value, received.get(0));
|
||||||
|
assertNotNull(errorRef.get());
|
||||||
|
assertEquals(boom, errorRef.get());
|
||||||
|
assertTrue(completed.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NoopObserver<T> implements StreamObserver<T> {
|
||||||
|
@Override
|
||||||
|
public void onNext(T value) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable t) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingClientCallStreamObserver<T> extends ClientCallStreamObserver<T> {
|
||||||
|
boolean cancelled;
|
||||||
|
String cancelMessage;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void disableAutoInboundFlowControl() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void request(int count) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMessageCompression(boolean enable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void cancel(String message, Throwable cause) {
|
||||||
|
cancelled = true;
|
||||||
|
cancelMessage = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNext(T value) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Throwable error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCompleted() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time guarantee that the parameter types still match the
|
||||||
|
// generic bounds — catches a regression where a subclass changes its
|
||||||
|
// request/response types out from under the shared base.
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static void typeBoundsCheck() {
|
||||||
|
MxGatewayStreamSubscription<StreamEventsRequest, MxEvent> a = new MxGatewayEventSubscription();
|
||||||
|
MxGatewayStreamSubscription<StreamAlarmsRequest, AlarmFeedMessage> b = new MxGatewayAlarmFeedSubscription();
|
||||||
|
MxGatewayStreamSubscription<QueryActiveAlarmsRequest, ActiveAlarmSnapshot> c =
|
||||||
|
new MxGatewayActiveAlarmsSubscription();
|
||||||
|
MxGatewayStreamSubscription<WatchDeployEventsRequest, DeployEvent> d = new DeployEventSubscription();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,31 +250,31 @@
|
|||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"operation": "open-session",
|
"operation": "open-session",
|
||||||
"command": "gradle :mxgateway-cli:run --args=\"open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name mxgw-java-smoke --json\""
|
"command": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"open-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --client-session-name mxgw-java-smoke --json\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operation": "register",
|
"operation": "register",
|
||||||
"command": "gradle :mxgateway-cli:run --args=\"register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --client-name mxgw-java-smoke --json\""
|
"command": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"register --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --client-name mxgw-java-smoke --json\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operation": "add-item",
|
"operation": "add-item",
|
||||||
"command": "gradle :mxgateway-cli:run --args=\"add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item TestChildObject.TestInt --json\""
|
"command": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item TestChildObject.TestInt --json\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operation": "advise",
|
"operation": "advise",
|
||||||
"command": "gradle :mxgateway-cli:run --args=\"advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --json\""
|
"command": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --json\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operation": "stream-events",
|
"operation": "stream-events",
|
||||||
"command": "gradle :mxgateway-cli:run --args=\"stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --limit 1 --json\""
|
"command": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --limit 1 --json\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"operation": "close-session",
|
"operation": "close-session",
|
||||||
"command": "gradle :mxgateway-cli:run --args=\"close-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --json\""
|
"command": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"close-session --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --json\""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"optionalWriteCommand": "gradle :mxgateway-cli:run --args=\"write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --type int32 --value <write-value> --json\"",
|
"optionalWriteCommand": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <session-id> --server-handle <server-handle> --item-handle <item-handle> --type int32 --value <write-value> --json\"",
|
||||||
"bundledSmokeCommand": "gradle :mxgateway-cli:run --args=\"smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestChildObject.TestInt --json\""
|
"bundledSmokeCommand": "gradle :zb-mom-ww-mxgateway-cli:run --args=\"smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestChildObject.TestInt --json\""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,28 @@ Support:
|
|||||||
- TLS channel with default roots,
|
- TLS channel with default roots,
|
||||||
- custom root certificate file.
|
- custom root certificate file.
|
||||||
|
|
||||||
|
### Trust posture (trust-on-first-use)
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). grpc-python exposes no per-channel skip-verify hook, so the client cannot
|
||||||
|
"accept any certificate" the way the other clients do. Instead, when the channel
|
||||||
|
is not plaintext and neither `ca_file` nor `require_certificate_validation` is
|
||||||
|
set, the TLS default is **trust-on-first-use**: the client fetches the server's
|
||||||
|
presented certificate once via `ssl.get_server_certificate` (an unverified
|
||||||
|
probe), pins it as the channel's only trust root, and — because the generated
|
||||||
|
certificate always carries a `localhost` SAN — defaults
|
||||||
|
`grpc.ssl_target_name_override` to `localhost` when no `server_name_override` was
|
||||||
|
supplied (tolerating dial-by-IP or a hostname mismatch). A failed probe is
|
||||||
|
surfaced as a transport error naming the endpoint.
|
||||||
|
|
||||||
|
To verify the gateway instead:
|
||||||
|
|
||||||
|
- set `ca_file` to verify against a specific CA, or
|
||||||
|
- set `require_certificate_validation=True` to verify against the system trust
|
||||||
|
roots.
|
||||||
|
|
||||||
|
Both bypass the TOFU path.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Expose `stream_events` as an async iterator. Canceling the task should cancel
|
Expose `stream_events` as an async iterator. Canceling the task should cancel
|
||||||
@@ -190,7 +212,7 @@ Use bounded smoke flow and always attempt `close_session` in `finally`.
|
|||||||
Use `pyproject.toml`. Publishable package name should be stable, for example:
|
Use `pyproject.toml`. Publishable package name should be stable, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
mxaccess-gateway-client
|
zb-mom-ww-mxaccess-gateway-client
|
||||||
```
|
```
|
||||||
|
|
||||||
Generated protobuf code should be regenerated through a documented command, not
|
Generated protobuf code should be regenerated through a documented command, not
|
||||||
|
|||||||
@@ -138,6 +138,49 @@ The methods return native Python types (`bool`, `datetime | None`, and a
|
|||||||
into the hierarchy without learning the underlying stub class. The
|
into the hierarchy without learning the underlying stub class. The
|
||||||
service requires the `metadata:read` scope on the API key.
|
service requires the `metadata:read` scope on the API key.
|
||||||
|
|
||||||
|
### Browsing lazily
|
||||||
|
|
||||||
|
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||||
|
time instead of loading the full hierarchy with `discover_hierarchy`. Pass an
|
||||||
|
empty request for root objects; subsequent calls set `parent_gobject_id`,
|
||||||
|
`parent_tag_name`, or `parent_contained_path`. Filter fields match
|
||||||
|
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
|
||||||
|
you know which nodes to expand. See
|
||||||
|
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||||
|
request and filter semantics.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
|
||||||
|
|
||||||
|
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
|
||||||
|
for child, has_children in zip(reply.children, reply.child_has_children):
|
||||||
|
print(child.tag_name, "expand=" + str(has_children))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with await GalaxyRepositoryClient.connect(
|
||||||
|
endpoint="localhost:5000",
|
||||||
|
api_key="<gateway-api-key>",
|
||||||
|
plaintext=True,
|
||||||
|
) as galaxy:
|
||||||
|
roots = await galaxy.browse()
|
||||||
|
for root in roots:
|
||||||
|
if root.has_children_hint:
|
||||||
|
await root.expand()
|
||||||
|
for child in root.children:
|
||||||
|
kind = "has children" if child.has_children_hint else "leaf"
|
||||||
|
print(f"{child.object.tag_name} ({kind})")
|
||||||
|
```
|
||||||
|
|
||||||
|
`expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
||||||
@@ -187,6 +230,17 @@ The client supports plaintext channels for local development, TLS with system
|
|||||||
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
roots, TLS with a custom `ca_file`, and an optional test server name override.
|
||||||
API keys are redacted from option repr output and CLI error output.
|
API keys are redacted from option repr output and CLI error output.
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||||
|
grpc-python has no per-channel skip-verify, so the lenient TLS default is
|
||||||
|
**trust-on-first-use**: with no `ca_file` and `require_certificate_validation`
|
||||||
|
left `False`, the client fetches the gateway's presented certificate once
|
||||||
|
(unverified) and pins it for the channel, defaulting the SNI/target-name override
|
||||||
|
to `localhost` (the generated certificate always carries a `localhost` SAN) when
|
||||||
|
none was supplied. To verify instead, pass `ca_file` to verify against a specific
|
||||||
|
CA, or set `require_certificate_validation=True` to verify against the system
|
||||||
|
trust roots. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
The CLI emits deterministic JSON for automation:
|
The CLI emits deterministic JSON for automation:
|
||||||
@@ -225,6 +279,19 @@ $env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
|
|||||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Installing from the Gitea PyPI Feed
|
||||||
|
|
||||||
|
The client publishes to the internal Gitea PyPI feed:
|
||||||
|
|
||||||
|
````bash
|
||||||
|
pip install \
|
||||||
|
--index-url https://gitea.dohertylan.com/api/packages/dohertj2/pypi/simple/ \
|
||||||
|
zb-mom-ww-mxaccess-gateway-client
|
||||||
|
````
|
||||||
|
|
||||||
|
If you need authentication (private feed), use `--extra-index-url` and either
|
||||||
|
a `~/.netrc` entry or `PIP_INDEX_URL=https://<user>:<token>@gitea.dohertylan.com/...`.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||||
|
|||||||
@@ -13,12 +13,35 @@ dependencies = [
|
|||||||
"grpcio>=1.80,<2",
|
"grpcio>=1.80,<2",
|
||||||
"protobuf>=6.33,<7",
|
"protobuf>=6.33,<7",
|
||||||
]
|
]
|
||||||
|
authors = [
|
||||||
|
{ name = "Joseph Doherty" },
|
||||||
|
]
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"License :: Other/Proprietary License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: System :: Distributed Computing",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"grpcio-tools>=1.80,<2",
|
"grpcio-tools>=1.80,<2",
|
||||||
"pytest>=9,<10",
|
"pytest>=9,<10",
|
||||||
"pytest-asyncio>=1.3,<2",
|
"pytest-asyncio>=1.3,<2",
|
||||||
|
"build>=1.2,<2",
|
||||||
|
"twine>=5,<6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ from .auth import merge_metadata
|
|||||||
from .errors import MxGatewayError, map_rpc_error
|
from .errors import MxGatewayError, map_rpc_error
|
||||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||||
from .options import ClientOptions, create_channel
|
from .options import BrowseChildrenOptions, ClientOptions, create_channel
|
||||||
|
|
||||||
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
|
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
|
||||||
|
_BROWSE_CHILDREN_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
class GalaxyRepositoryClient:
|
class GalaxyRepositoryClient:
|
||||||
@@ -139,6 +140,89 @@ class GalaxyRepositoryClient:
|
|||||||
)
|
)
|
||||||
seen_page_tokens.add(page_token)
|
seen_page_tokens.add(page_token)
|
||||||
|
|
||||||
|
async def browse_children_raw(
|
||||||
|
self, request: galaxy_pb.BrowseChildrenRequest
|
||||||
|
) -> galaxy_pb.BrowseChildrenReply:
|
||||||
|
"""Issue one BrowseChildren RPC and return the raw reply.
|
||||||
|
|
||||||
|
Lower-level escape hatch for callers that need direct page-token control
|
||||||
|
or do not want LazyBrowseNode wrapping. Most callers should use
|
||||||
|
:py:meth:`browse` and :py:meth:`LazyBrowseNode.expand` instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self._unary(
|
||||||
|
"browse children",
|
||||||
|
self.raw_stub.BrowseChildren,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def browse(
|
||||||
|
self,
|
||||||
|
options: BrowseChildrenOptions | None = None,
|
||||||
|
) -> list["LazyBrowseNode"]:
|
||||||
|
"""Return the root browse nodes for lazy hierarchy traversal.
|
||||||
|
|
||||||
|
Each returned ``LazyBrowseNode`` wraps a Galaxy object whose direct
|
||||||
|
children can be loaded on demand by ``await node.expand()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
effective = options or BrowseChildrenOptions()
|
||||||
|
return [
|
||||||
|
node
|
||||||
|
async for node in self._iter_browse_children(
|
||||||
|
parent_gobject_id=None,
|
||||||
|
options=effective,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _iter_browse_children(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
parent_gobject_id: int | None,
|
||||||
|
options: BrowseChildrenOptions,
|
||||||
|
) -> AsyncIterator["LazyBrowseNode"]:
|
||||||
|
page_token = ""
|
||||||
|
seen_page_tokens: set[str] = set()
|
||||||
|
while True:
|
||||||
|
request = galaxy_pb.BrowseChildrenRequest(
|
||||||
|
page_size=_BROWSE_CHILDREN_PAGE_SIZE,
|
||||||
|
page_token=page_token,
|
||||||
|
alarm_bearing_only=options.alarm_bearing_only,
|
||||||
|
historized_only=options.historized_only,
|
||||||
|
)
|
||||||
|
if parent_gobject_id is not None:
|
||||||
|
request.parent_gobject_id = parent_gobject_id
|
||||||
|
if options.category_ids:
|
||||||
|
request.category_ids.extend(options.category_ids)
|
||||||
|
if options.template_chain_contains:
|
||||||
|
request.template_chain_contains.extend(options.template_chain_contains)
|
||||||
|
if options.tag_name_glob:
|
||||||
|
request.tag_name_glob = options.tag_name_glob
|
||||||
|
if options.include_attributes is not None:
|
||||||
|
request.include_attributes = options.include_attributes
|
||||||
|
|
||||||
|
reply = await self._unary(
|
||||||
|
"browse children",
|
||||||
|
self.raw_stub.BrowseChildren,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, obj in enumerate(reply.children):
|
||||||
|
hint = (
|
||||||
|
index < len(reply.child_has_children)
|
||||||
|
and bool(reply.child_has_children[index])
|
||||||
|
)
|
||||||
|
yield LazyBrowseNode(self, obj, hint, options)
|
||||||
|
|
||||||
|
page_token = reply.next_page_token
|
||||||
|
if not page_token:
|
||||||
|
return
|
||||||
|
if page_token in seen_page_tokens:
|
||||||
|
raise MxGatewayError(
|
||||||
|
f"galaxy browse children returned repeated page token {page_token!r}"
|
||||||
|
)
|
||||||
|
seen_page_tokens.add(page_token)
|
||||||
|
|
||||||
def watch_deploy_events(
|
def watch_deploy_events(
|
||||||
self,
|
self,
|
||||||
last_seen_deploy_time: datetime | None = None,
|
last_seen_deploy_time: datetime | None = None,
|
||||||
@@ -202,6 +286,67 @@ class GalaxyRepositoryClient:
|
|||||||
raise map_rpc_error(operation, error) from error
|
raise map_rpc_error(operation, error) from error
|
||||||
|
|
||||||
|
|
||||||
|
class LazyBrowseNode:
|
||||||
|
"""One node in a lazy-loaded Galaxy browse tree.
|
||||||
|
|
||||||
|
Calling ``expand`` once fetches direct children (paginating as needed)
|
||||||
|
and populates ``children``. Subsequent calls are no-ops so callers can
|
||||||
|
drive UI expand toggles without de-duping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: "GalaxyRepositoryClient",
|
||||||
|
obj: galaxy_pb.GalaxyObject,
|
||||||
|
has_children_hint: bool,
|
||||||
|
options: BrowseChildrenOptions,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize a node bound to its owning client and filter set."""
|
||||||
|
self._client = client
|
||||||
|
self._object = obj
|
||||||
|
self._has_children_hint = has_children_hint
|
||||||
|
self._options = options
|
||||||
|
self._children: list[LazyBrowseNode] = []
|
||||||
|
self._is_expanded = False
|
||||||
|
self._expand_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def object(self) -> galaxy_pb.GalaxyObject:
|
||||||
|
"""Return the underlying ``GalaxyObject`` proto for this node."""
|
||||||
|
return self._object
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_children_hint(self) -> bool:
|
||||||
|
"""Return the server hint about whether this node has children."""
|
||||||
|
return self._has_children_hint
|
||||||
|
|
||||||
|
@property
|
||||||
|
def children(self) -> list["LazyBrowseNode"]:
|
||||||
|
"""Return a copy of the loaded child nodes (empty until expanded)."""
|
||||||
|
return list(self._children)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expanded(self) -> bool:
|
||||||
|
"""Return whether ``expand`` has already populated ``children``."""
|
||||||
|
return self._is_expanded
|
||||||
|
|
||||||
|
async def expand(self) -> None:
|
||||||
|
"""Fetch direct children of this node; no-op on subsequent calls."""
|
||||||
|
if self._is_expanded:
|
||||||
|
return
|
||||||
|
async with self._expand_lock:
|
||||||
|
if self._is_expanded:
|
||||||
|
return
|
||||||
|
new_children: list[LazyBrowseNode] = []
|
||||||
|
async for child in self._client._iter_browse_children(
|
||||||
|
parent_gobject_id=self._object.gobject_id,
|
||||||
|
options=self._options,
|
||||||
|
):
|
||||||
|
new_children.append(child)
|
||||||
|
self._children.extend(new_children)
|
||||||
|
self._is_expanded = True
|
||||||
|
|
||||||
|
|
||||||
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
|
||||||
try:
|
try:
|
||||||
async for event in call:
|
async for event in call:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
|
|||||||
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
|
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
|
||||||
|
|
||||||
|
|
||||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\"\xdc\x02\n\x15\x42rowseChildrenRequest\x12\x1b\n\x11parent_gobject_id\x18\x01 \x01(\x05H\x00\x12\x19\n\x0fparent_tag_name\x18\x02 \x01(\tH\x00\x12\x1f\n\x15parent_contained_path\x18\x03 \x01(\tH\x00\x12\x11\n\tpage_size\x18\x04 \x01(\x05\x12\x12\n\npage_token\x18\x05 \x01(\t\x12\x14\n\x0c\x63\x61tegory_ids\x18\x06 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x07 \x03(\t\x12\x15\n\rtag_name_glob\x18\x08 \x01(\t\x12\x1f\n\x12include_attributes\x18\t \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\n \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0b \x01(\x08\x42\x08\n\x06parentB\x15\n\x13_include_attributes\"\xb3\x01\n\x13\x42rowseChildrenReply\x12\x34\n\x08\x63hildren\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x19\n\x11total_child_count\x18\x03 \x01(\x05\x12\x1a\n\x12\x63hild_has_children\x18\x04 \x03(\x08\x12\x16\n\x0e\x63\x61\x63he_sequence\x18\x05 \x01(\x04\x32\xb6\x04\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n\x0e\x42rowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
||||||
|
|
||||||
_globals = globals()
|
_globals = globals()
|
||||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
@@ -54,6 +54,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
|||||||
_globals['_GALAXYOBJECT']._serialized_end=1416
|
_globals['_GALAXYOBJECT']._serialized_end=1416
|
||||||
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
|
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
|
||||||
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
|
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
|
||||||
_globals['_GALAXYREPOSITORY']._serialized_start=1718
|
_globals['_BROWSECHILDRENREQUEST']._serialized_start=1718
|
||||||
_globals['_GALAXYREPOSITORY']._serialized_end=2178
|
_globals['_BROWSECHILDRENREQUEST']._serialized_end=2066
|
||||||
|
_globals['_BROWSECHILDRENREPLY']._serialized_start=2069
|
||||||
|
_globals['_BROWSECHILDRENREPLY']._serialized_end=2248
|
||||||
|
_globals['_GALAXYREPOSITORY']._serialized_start=2251
|
||||||
|
_globals['_GALAXYREPOSITORY']._serialized_end=2817
|
||||||
# @@protoc_insertion_point(module_scope)
|
# @@protoc_insertion_point(module_scope)
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ class GalaxyRepositoryStub(object):
|
|||||||
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
|
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
|
||||||
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
|
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
self.BrowseChildren = channel.unary_unary(
|
||||||
|
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
|
||||||
|
request_serializer=galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
|
||||||
|
response_deserializer=galaxy__repository__pb2.BrowseChildrenReply.FromString,
|
||||||
|
_registered_method=True)
|
||||||
|
|
||||||
|
|
||||||
class GalaxyRepositoryServicer(object):
|
class GalaxyRepositoryServicer(object):
|
||||||
@@ -111,6 +116,16 @@ class GalaxyRepositoryServicer(object):
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('Method not implemented!')
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
def BrowseChildren(self, request, context):
|
||||||
|
"""Returns the direct children of a parent object (or the root objects when
|
||||||
|
`parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||||
|
one level at a time instead of paging the full hierarchy. Filters mirror
|
||||||
|
DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||||
|
"""
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
context.set_details('Method not implemented!')
|
||||||
|
raise NotImplementedError('Method not implemented!')
|
||||||
|
|
||||||
|
|
||||||
def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
@@ -134,6 +149,11 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
|||||||
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
|
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
|
||||||
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
|
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
|
||||||
),
|
),
|
||||||
|
'BrowseChildren': grpc.unary_unary_rpc_method_handler(
|
||||||
|
servicer.BrowseChildren,
|
||||||
|
request_deserializer=galaxy__repository__pb2.BrowseChildrenRequest.FromString,
|
||||||
|
response_serializer=galaxy__repository__pb2.BrowseChildrenReply.SerializeToString,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
generic_handler = grpc.method_handlers_generic_handler(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
|
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
|
||||||
@@ -263,3 +283,30 @@ class GalaxyRepository(object):
|
|||||||
timeout,
|
timeout,
|
||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_registered_method=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def BrowseChildren(request,
|
||||||
|
target,
|
||||||
|
options=(),
|
||||||
|
channel_credentials=None,
|
||||||
|
call_credentials=None,
|
||||||
|
insecure=False,
|
||||||
|
compression=None,
|
||||||
|
wait_for_ready=None,
|
||||||
|
timeout=None,
|
||||||
|
metadata=None):
|
||||||
|
return grpc.experimental.unary_unary(
|
||||||
|
request,
|
||||||
|
target,
|
||||||
|
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
|
||||||
|
galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
|
||||||
|
galaxy__repository__pb2.BrowseChildrenReply.FromString,
|
||||||
|
options,
|
||||||
|
channel_credentials,
|
||||||
|
insecure,
|
||||||
|
call_credentials,
|
||||||
|
compression,
|
||||||
|
wait_for_ready,
|
||||||
|
timeout,
|
||||||
|
metadata,
|
||||||
|
_registered_method=True)
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ class MxAccessGatewayServicer(object):
|
|||||||
reconnect to seed Part 9 client state, or to reconcile alarms that may
|
reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||||
have been missed during a transport blip. Streamed so callers can
|
have been missed during a transport blip. Streamed so callers can
|
||||||
begin processing without buffering the full set.
|
begin processing without buffering the full set.
|
||||||
|
`QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
|
||||||
|
snapshot to alarms whose `alarm_full_reference` starts with the given
|
||||||
|
prefix; an empty prefix returns the full set.
|
||||||
"""
|
"""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
import ssl
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
|
|
||||||
from .auth import REDACTED, ApiKey
|
from .auth import REDACTED, ApiKey
|
||||||
|
from .errors import MxGatewayTransportError
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -18,6 +21,7 @@ class ClientOptions:
|
|||||||
api_key: str | ApiKey | None = None
|
api_key: str | ApiKey | None = None
|
||||||
plaintext: bool = False
|
plaintext: bool = False
|
||||||
ca_file: str | None = None
|
ca_file: str | None = None
|
||||||
|
require_certificate_validation: bool = False
|
||||||
server_name_override: str | None = None
|
server_name_override: str | None = None
|
||||||
call_timeout: float | None = 30.0
|
call_timeout: float | None = 30.0
|
||||||
stream_timeout: float | None = None
|
stream_timeout: float | None = None
|
||||||
@@ -44,6 +48,7 @@ class ClientOptions:
|
|||||||
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
f"{type(self).__name__}(endpoint={self.endpoint!r}, "
|
||||||
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
|
f"api_key={api_key!r}, plaintext={self.plaintext!r}, "
|
||||||
f"ca_file={self.ca_file!r}, "
|
f"ca_file={self.ca_file!r}, "
|
||||||
|
f"require_certificate_validation={self.require_certificate_validation!r}, "
|
||||||
f"server_name_override={self.server_name_override!r}, "
|
f"server_name_override={self.server_name_override!r}, "
|
||||||
f"call_timeout={self.call_timeout!r}, "
|
f"call_timeout={self.call_timeout!r}, "
|
||||||
f"stream_timeout={self.stream_timeout!r}, "
|
f"stream_timeout={self.stream_timeout!r}, "
|
||||||
@@ -51,8 +56,51 @@ class ClientOptions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BrowseChildrenOptions:
|
||||||
|
"""Filters and shape options for ``GalaxyRepositoryClient.browse``.
|
||||||
|
|
||||||
|
Mirrors the AND-combined filter set on ``BrowseChildrenRequest`` so a
|
||||||
|
single instance can be re-used across an entire lazy browse session
|
||||||
|
(the filter set is part of the page-token contract).
|
||||||
|
"""
|
||||||
|
|
||||||
|
category_ids: Sequence[int] = field(default_factory=tuple)
|
||||||
|
template_chain_contains: Sequence[str] = field(default_factory=tuple)
|
||||||
|
tag_name_glob: str | None = None
|
||||||
|
include_attributes: bool | None = None
|
||||||
|
alarm_bearing_only: bool = False
|
||||||
|
historized_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _split_authority(endpoint: str) -> tuple[str, int]:
|
||||||
|
"""Split a gRPC target (optionally scheme-prefixed) into (host, port).
|
||||||
|
|
||||||
|
Handles bracketed IPv6 literals (e.g. ``[::1]:5120`` or bare ``[::1]``),
|
||||||
|
returning the host without brackets so it is safe to pass to
|
||||||
|
``ssl.get_server_certificate``.
|
||||||
|
"""
|
||||||
|
target = endpoint.split("://", 1)[-1]
|
||||||
|
if target.startswith("["):
|
||||||
|
# Bracketed IPv6: "[::1]:5120" or "[::1]"
|
||||||
|
bracket_end = target.find("]")
|
||||||
|
host = target[1:bracket_end] # strip surrounding brackets
|
||||||
|
remainder = target[bracket_end + 1 :] # ":5120" or ""
|
||||||
|
port_str = remainder.lstrip(":")
|
||||||
|
return (host, int(port_str) if port_str else 443)
|
||||||
|
host, _, port = target.rpartition(":")
|
||||||
|
return (host or "localhost", int(port) if port else 443)
|
||||||
|
|
||||||
|
|
||||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||||
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
|
"""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
|
||||||
|
the server's presented certificate is fetched once (unverified) and pinned
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
channel_options: list[tuple[str, str | int]] = [
|
channel_options: list[tuple[str, str | int]] = [
|
||||||
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
||||||
@@ -64,11 +112,28 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
|||||||
if options.plaintext:
|
if options.plaintext:
|
||||||
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
|
||||||
|
|
||||||
root_certificates = None
|
|
||||||
if options.ca_file:
|
if options.ca_file:
|
||||||
root_certificates = Path(options.ca_file).read_bytes()
|
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).
|
||||||
|
host, port = _split_authority(options.endpoint)
|
||||||
|
try:
|
||||||
|
presented = ssl.get_server_certificate((host, port))
|
||||||
|
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"))
|
||||||
|
|
||||||
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
|
|
||||||
return grpc.aio.secure_channel(
|
return grpc.aio.secure_channel(
|
||||||
options.endpoint,
|
options.endpoint,
|
||||||
credentials,
|
credentials,
|
||||||
|
|||||||
@@ -72,27 +72,83 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_create_channel_uses_tls_channel_tofu_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
calls: list[tuple[str, object, object]] = []
|
"""Default TLS (no ca_file, no require_certificate_validation) uses TOFU:
|
||||||
|
fetches the server cert unverified, pins it as root_certificates, and adds
|
||||||
|
grpc.ssl_target_name_override = "localhost" automatically.
|
||||||
|
"""
|
||||||
|
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||||
|
get_cert_calls: list[tuple[str, int]] = []
|
||||||
|
|
||||||
def fake_credentials(*, root_certificates: object) -> str:
|
def fake_get_server_certificate(addr: tuple[str, int]) -> str:
|
||||||
assert root_certificates is None
|
get_cert_calls.append(addr)
|
||||||
|
return _DUMMY_PEM
|
||||||
|
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||||
|
cred_calls.append(root_certificates)
|
||||||
return "creds"
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
calls.append((endpoint, credentials, options))
|
channel_calls.append((endpoint, credentials, options))
|
||||||
return "tls-channel"
|
return "tls-channel"
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||||
options_module.grpc,
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
"ssl_channel_credentials",
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
fake_credentials,
|
|
||||||
|
channel = create_channel(
|
||||||
|
ClientOptions(endpoint="gateway.example:5001"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert channel == "tls-channel"
|
||||||
|
# TOFU: should have fetched the cert from the server (host, port)
|
||||||
|
assert get_cert_calls == [("gateway.example", 5001)]
|
||||||
|
# Pinned the fetched PEM bytes as root_certificates
|
||||||
|
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||||
|
# Auto-injected localhost override (no server_name_override supplied)
|
||||||
|
assert channel_calls == [
|
||||||
|
(
|
||||||
|
"gateway.example:5001",
|
||||||
|
"creds",
|
||||||
|
[
|
||||||
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.ssl_target_name_override", "localhost"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_channel_uses_tls_channel_tofu_respects_server_name_override(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""When server_name_override is set, TOFU still runs but does NOT add the
|
||||||
|
auto-localhost override (the explicit override is already in channel_options).
|
||||||
|
"""
|
||||||
|
_DUMMY_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
options_module.grpc.aio,
|
options_module.ssl,
|
||||||
"secure_channel",
|
"get_server_certificate",
|
||||||
fake_secure_channel,
|
lambda addr: _DUMMY_PEM,
|
||||||
)
|
)
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||||
|
cred_calls.append(root_certificates)
|
||||||
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
|
channel_calls.append((endpoint, credentials, options))
|
||||||
|
return "tls-channel"
|
||||||
|
|
||||||
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
|
|
||||||
channel = create_channel(
|
channel = create_channel(
|
||||||
ClientOptions(
|
ClientOptions(
|
||||||
@@ -102,14 +158,121 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert channel == "tls-channel"
|
assert channel == "tls-channel"
|
||||||
assert calls == [
|
assert cred_calls == [_DUMMY_PEM.encode("ascii")]
|
||||||
(
|
assert channel_calls == [
|
||||||
"gateway.example:5001",
|
(
|
||||||
"creds",
|
"gateway.example:5001",
|
||||||
[
|
"creds",
|
||||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
[
|
||||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
("grpc.ssl_target_name_override", "gateway.test"),
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
],
|
# Explicit override from ClientOptions — not the auto-localhost one
|
||||||
),
|
("grpc.ssl_target_name_override", "gateway.test"),
|
||||||
]
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_channel_uses_tls_channel_require_cert_validation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""require_certificate_validation=True uses system trust (no TOFU, no root_certificates)."""
|
||||||
|
get_cert_called = False
|
||||||
|
|
||||||
|
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
|
||||||
|
nonlocal get_cert_called
|
||||||
|
get_cert_called = True
|
||||||
|
return "SHOULD_NOT_BE_CALLED"
|
||||||
|
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(**kwargs: object) -> str:
|
||||||
|
cred_calls.append(kwargs)
|
||||||
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
|
channel_calls.append((endpoint, credentials, options))
|
||||||
|
return "tls-channel"
|
||||||
|
|
||||||
|
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||||
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
|
|
||||||
|
channel = create_channel(
|
||||||
|
ClientOptions(
|
||||||
|
endpoint="gateway.example:5001",
|
||||||
|
require_certificate_validation=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel == "tls-channel"
|
||||||
|
# Must NOT call TOFU prefetch
|
||||||
|
assert not get_cert_called
|
||||||
|
# ssl_channel_credentials() called with NO keyword args (system trust)
|
||||||
|
assert cred_calls == [{}]
|
||||||
|
assert channel_calls == [
|
||||||
|
(
|
||||||
|
"gateway.example:5001",
|
||||||
|
"creds",
|
||||||
|
[
|
||||||
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_channel_uses_tls_channel_ca_file(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: pytest.TempPathFactory,
|
||||||
|
) -> None:
|
||||||
|
"""ca_file path: reads the PEM file, passes bytes as root_certificates, skips TOFU."""
|
||||||
|
ca_pem = b"-----BEGIN CERTIFICATE-----\nY2FkYXRh\n-----END CERTIFICATE-----\n"
|
||||||
|
ca_file = tmp_path / "ca.pem"
|
||||||
|
ca_file.write_bytes(ca_pem)
|
||||||
|
|
||||||
|
get_cert_called = False
|
||||||
|
|
||||||
|
def fake_get_server_certificate(addr: object) -> str: # pragma: no cover
|
||||||
|
nonlocal get_cert_called
|
||||||
|
get_cert_called = True
|
||||||
|
return "SHOULD_NOT_BE_CALLED"
|
||||||
|
|
||||||
|
cred_calls: list[object] = []
|
||||||
|
|
||||||
|
def fake_credentials(*, root_certificates: object = None) -> str:
|
||||||
|
cred_calls.append(root_certificates)
|
||||||
|
return "creds"
|
||||||
|
|
||||||
|
channel_calls: list[tuple[str, object, object]] = []
|
||||||
|
|
||||||
|
def fake_secure_channel(endpoint: str, credentials: object, *, options: object) -> str:
|
||||||
|
channel_calls.append((endpoint, credentials, options))
|
||||||
|
return "tls-channel"
|
||||||
|
|
||||||
|
monkeypatch.setattr(options_module.ssl, "get_server_certificate", fake_get_server_certificate)
|
||||||
|
monkeypatch.setattr(options_module.grpc, "ssl_channel_credentials", fake_credentials)
|
||||||
|
monkeypatch.setattr(options_module.grpc.aio, "secure_channel", fake_secure_channel)
|
||||||
|
|
||||||
|
channel = create_channel(
|
||||||
|
ClientOptions(
|
||||||
|
endpoint="gateway.example:5001",
|
||||||
|
ca_file=str(ca_file),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert channel == "tls-channel"
|
||||||
|
assert not get_cert_called
|
||||||
|
assert cred_calls == [ca_pem]
|
||||||
|
assert channel_calls == [
|
||||||
|
(
|
||||||
|
"gateway.example:5001",
|
||||||
|
"creds",
|
||||||
|
[
|
||||||
|
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||||
|
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import asyncio
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import grpc
|
||||||
import pytest
|
import pytest
|
||||||
from google.protobuf.timestamp_pb2 import Timestamp
|
from google.protobuf.timestamp_pb2 import Timestamp
|
||||||
|
|
||||||
from zb_mom_ww_mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
|
from zb_mom_ww_mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
|
||||||
|
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||||
|
from zb_mom_ww_mxgateway.galaxy import LazyBrowseNode
|
||||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
|
||||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||||
|
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
|
||||||
|
|
||||||
|
|
||||||
def test_galaxy_messages_import() -> None:
|
def test_galaxy_messages_import() -> None:
|
||||||
@@ -268,15 +272,281 @@ async def test_close_marks_channel_closed_when_no_real_channel() -> None:
|
|||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _obj(gid: int, tag: str, is_area: bool = False) -> galaxy_pb.GalaxyObject:
|
||||||
|
return galaxy_pb.GalaxyObject(
|
||||||
|
gobject_id=gid, tag_name=tag, browse_name=tag, is_area=is_area,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_browse_reply(
|
||||||
|
children: list[galaxy_pb.GalaxyObject],
|
||||||
|
child_has_children: list[bool],
|
||||||
|
cache_sequence: int,
|
||||||
|
next_page_token: str = "",
|
||||||
|
) -> galaxy_pb.BrowseChildrenReply:
|
||||||
|
reply = galaxy_pb.BrowseChildrenReply(
|
||||||
|
total_child_count=len(children),
|
||||||
|
cache_sequence=cache_sequence,
|
||||||
|
next_page_token=next_page_token,
|
||||||
|
)
|
||||||
|
reply.children.extend(children)
|
||||||
|
reply.child_has_children.extend(child_has_children)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_aio_rpc_error(code: grpc.StatusCode, details: str) -> grpc.aio.AioRpcError:
|
||||||
|
return grpc.aio.AioRpcError(
|
||||||
|
code=code,
|
||||||
|
initial_metadata=grpc.aio.Metadata(),
|
||||||
|
trailing_metadata=grpc.aio.Metadata(),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_no_parent_returns_roots() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(1, "Area_A", is_area=True), _obj(2, "Area_B", is_area=True)],
|
||||||
|
child_has_children=[True, False],
|
||||||
|
cache_sequence=7,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
|
||||||
|
assert len(roots) == 2
|
||||||
|
assert all(isinstance(node, LazyBrowseNode) for node in roots)
|
||||||
|
assert roots[0].object.tag_name == "Area_A"
|
||||||
|
assert roots[0].has_children_hint is True
|
||||||
|
assert roots[1].has_children_hint is False
|
||||||
|
assert roots[0].is_expanded is False
|
||||||
|
request = stub.browse_children.requests[0]
|
||||||
|
assert request.WhichOneof("parent") is None
|
||||||
|
assert request.page_size == 500
|
||||||
|
assert request.page_token == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_expand_populates_children_and_marks_expanded() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(1, "Area_A", is_area=True)],
|
||||||
|
child_has_children=[True],
|
||||||
|
cache_sequence=1,
|
||||||
|
),
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(11, "Child_A"), _obj(12, "Child_B")],
|
||||||
|
child_has_children=[False, False],
|
||||||
|
cache_sequence=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
await roots[0].expand()
|
||||||
|
|
||||||
|
assert roots[0].is_expanded is True
|
||||||
|
assert [n.object.tag_name for n in roots[0].children] == ["Child_A", "Child_B"]
|
||||||
|
assert len(stub.browse_children.requests) == 2
|
||||||
|
expand_request = stub.browse_children.requests[1]
|
||||||
|
assert expand_request.WhichOneof("parent") == "parent_gobject_id"
|
||||||
|
assert expand_request.parent_gobject_id == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_expand_idempotent_no_second_rpc() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(1, "Area_A", is_area=True)],
|
||||||
|
child_has_children=[True],
|
||||||
|
cache_sequence=1,
|
||||||
|
),
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(11, "Child_A")],
|
||||||
|
child_has_children=[False],
|
||||||
|
cache_sequence=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
await roots[0].expand()
|
||||||
|
await roots[0].expand()
|
||||||
|
|
||||||
|
assert len(stub.browse_children.requests) == 2
|
||||||
|
assert len(roots[0].children) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_expand_concurrent_callers_only_fire_one_rpc() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7),
|
||||||
|
_build_browse_reply([_obj(2, "Mixer_001")], [False], 7),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
# Ten concurrent expand calls on the same node should issue exactly one RPC.
|
||||||
|
await asyncio.gather(*(roots[0].expand() for _ in range(10)))
|
||||||
|
|
||||||
|
assert roots[0].is_expanded
|
||||||
|
assert len(roots[0].children) == 1
|
||||||
|
# 1 roots fetch + exactly 1 expand fetch = 2 total
|
||||||
|
assert len(stub.browse_children.requests) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(99, "Stale_Parent", is_area=True)],
|
||||||
|
child_has_children=[True],
|
||||||
|
cache_sequence=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
stub.browse_children.exceptions = [
|
||||||
|
None,
|
||||||
|
_fake_aio_rpc_error(grpc.StatusCode.NOT_FOUND, "parent not found"),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
with pytest.raises(MxGatewayError):
|
||||||
|
await roots[0].expand()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_expand_multi_page_gathers_all_pages() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(7, "Area_Big", is_area=True)],
|
||||||
|
child_has_children=[True],
|
||||||
|
cache_sequence=2,
|
||||||
|
),
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(71, "Child_1"), _obj(72, "Child_2")],
|
||||||
|
child_has_children=[False, False],
|
||||||
|
cache_sequence=2,
|
||||||
|
next_page_token="7:abc:2",
|
||||||
|
),
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(73, "Child_3")],
|
||||||
|
child_has_children=[False],
|
||||||
|
cache_sequence=2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = await client.browse()
|
||||||
|
await roots[0].expand()
|
||||||
|
|
||||||
|
assert [n.object.tag_name for n in roots[0].children] == ["Child_1", "Child_2", "Child_3"]
|
||||||
|
assert len(stub.browse_children.requests) == 3
|
||||||
|
assert stub.browse_children.requests[2].page_token == "7:abc:2"
|
||||||
|
assert stub.browse_children.requests[2].parent_gobject_id == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_with_filter_forwards_to_request() -> None:
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
stub.browse_children.replies = [
|
||||||
|
_build_browse_reply(
|
||||||
|
children=[_obj(1, "Area_A", is_area=True)],
|
||||||
|
child_has_children=[False],
|
||||||
|
cache_sequence=3,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
client = await GalaxyRepositoryClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
options = BrowseChildrenOptions(
|
||||||
|
category_ids=(4, 5),
|
||||||
|
template_chain_contains=("$DelmiaReceiver",),
|
||||||
|
tag_name_glob="Area_*",
|
||||||
|
include_attributes=True,
|
||||||
|
alarm_bearing_only=True,
|
||||||
|
historized_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
await client.browse(options)
|
||||||
|
|
||||||
|
request = stub.browse_children.requests[0]
|
||||||
|
assert list(request.category_ids) == [4, 5]
|
||||||
|
assert list(request.template_chain_contains) == ["$DelmiaReceiver"]
|
||||||
|
assert request.tag_name_glob == "Area_*"
|
||||||
|
assert request.HasField("include_attributes")
|
||||||
|
assert request.include_attributes is True
|
||||||
|
assert request.alarm_bearing_only is True
|
||||||
|
assert request.historized_only is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browse_children_raw_returns_reply_unwrapped() -> None:
|
||||||
|
"""browse_children_raw forwards the request to the stub and returns the raw reply."""
|
||||||
|
stub = FakeGalaxyStub()
|
||||||
|
expected = _build_browse_reply(
|
||||||
|
children=[_obj(1, "Plant", is_area=True)],
|
||||||
|
child_has_children=[True],
|
||||||
|
cache_sequence=42,
|
||||||
|
)
|
||||||
|
stub.browse_children.replies = [expected]
|
||||||
|
|
||||||
|
async with await GalaxyRepositoryClient.connect(
|
||||||
|
endpoint="fake",
|
||||||
|
plaintext=True,
|
||||||
|
stub=stub,
|
||||||
|
) as client:
|
||||||
|
request = galaxy_pb.BrowseChildrenRequest(
|
||||||
|
page_size=10,
|
||||||
|
tag_name_glob="Plant*",
|
||||||
|
)
|
||||||
|
reply = await client.browse_children_raw(request)
|
||||||
|
|
||||||
|
assert reply.cache_sequence == 42
|
||||||
|
assert len(reply.children) == 1
|
||||||
|
assert reply.children[0].tag_name == "Plant"
|
||||||
|
assert len(stub.browse_children.requests) == 1
|
||||||
|
assert stub.browse_children.requests[0].tag_name_glob == "Plant*"
|
||||||
|
|
||||||
|
|
||||||
class FakeGalaxyStub:
|
class FakeGalaxyStub:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
||||||
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
|
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
|
||||||
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
|
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
|
||||||
|
self.browse_children = FakeUnary([galaxy_pb.BrowseChildrenReply()])
|
||||||
self.watch_deploy_events = FakeStream([])
|
self.watch_deploy_events = FakeStream([])
|
||||||
self.TestConnection = self.test_connection
|
self.TestConnection = self.test_connection
|
||||||
self.GetLastDeployTime = self.get_last_deploy_time
|
self.GetLastDeployTime = self.get_last_deploy_time
|
||||||
self.DiscoverHierarchy = self.discover_hierarchy
|
self.DiscoverHierarchy = self.discover_hierarchy
|
||||||
|
self.BrowseChildren = self.browse_children
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
|
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
|
||||||
@@ -287,6 +557,8 @@ class FakeUnary:
|
|||||||
def __init__(self, replies: list[Any]) -> None:
|
def __init__(self, replies: list[Any]) -> None:
|
||||||
self.replies = replies
|
self.replies = replies
|
||||||
self.requests: list[Any] = []
|
self.requests: list[Any] = []
|
||||||
|
# None entries mean "no exception on this call"; aligns with the replies queue index-by-index.
|
||||||
|
self.exceptions: list[BaseException | None] = []
|
||||||
self.metadata: tuple[tuple[str, str], ...] | None = None
|
self.metadata: tuple[tuple[str, str], ...] | None = None
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
@@ -298,6 +570,10 @@ class FakeUnary:
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
self.requests.append(request)
|
self.requests.append(request)
|
||||||
self.metadata = metadata
|
self.metadata = metadata
|
||||||
|
if self.exceptions:
|
||||||
|
exc = self.exceptions.pop(0)
|
||||||
|
if exc is not None:
|
||||||
|
raise exc
|
||||||
return self.replies.pop(0)
|
return self.replies.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""TLS behaviour tests for ``create_channel``.
|
||||||
|
|
||||||
|
These spin up a real loopback ``grpc.aio`` server with a freshly generated
|
||||||
|
self-signed certificate (carrying a ``localhost`` SAN, mirroring the gateway's
|
||||||
|
auto-generated cert) and assert the lenient TOFU default lets a client connect
|
||||||
|
without any CA configured.
|
||||||
|
|
||||||
|
Marked ``tls`` and skipped unless ``MXGATEWAY_RUN_TLS_TESTS=1`` because loopback
|
||||||
|
TLS handshakes can be timing-flaky on shared CI runners. This mirrors how the
|
||||||
|
suite gates anything that depends on real sockets rather than fakes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from zb_mom_ww_mxgateway import ClientOptions
|
||||||
|
from zb_mom_ww_mxgateway.errors import MxGatewayTransportError
|
||||||
|
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
|
||||||
|
from zb_mom_ww_mxgateway.options import create_channel
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.tls
|
||||||
|
|
||||||
|
_RUN_TLS_TESTS = os.environ.get("MXGATEWAY_RUN_TLS_TESTS") == "1"
|
||||||
|
_OPENSSL = shutil.which("openssl")
|
||||||
|
|
||||||
|
requires_tls = pytest.mark.skipif(
|
||||||
|
not _RUN_TLS_TESTS,
|
||||||
|
reason="set MXGATEWAY_RUN_TLS_TESTS=1 to run loopback TLS tests",
|
||||||
|
)
|
||||||
|
requires_openssl = pytest.mark.skipif(
|
||||||
|
_OPENSSL is None,
|
||||||
|
reason="openssl CLI is required to generate a self-signed test certificate",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_self_signed_cert(directory: Path) -> tuple[Path, Path]:
|
||||||
|
"""Generate a self-signed cert/key pair with a ``localhost`` SAN."""
|
||||||
|
key_path = directory / "server.key"
|
||||||
|
cert_path = directory / "server.crt"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
str(_OPENSSL),
|
||||||
|
"req",
|
||||||
|
"-x509",
|
||||||
|
"-newkey",
|
||||||
|
"rsa:2048",
|
||||||
|
"-nodes",
|
||||||
|
"-keyout",
|
||||||
|
str(key_path),
|
||||||
|
"-out",
|
||||||
|
str(cert_path),
|
||||||
|
"-days",
|
||||||
|
"1",
|
||||||
|
"-subj",
|
||||||
|
"/CN=mxgateway-test",
|
||||||
|
"-addext",
|
||||||
|
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
class _StaticGatewayServicer(pb_grpc.MxAccessGatewayServicer):
|
||||||
|
"""Minimal servicer answering ``OpenSession`` with a fixed session id."""
|
||||||
|
|
||||||
|
async def OpenSession( # noqa: N802 - generated gRPC method name
|
||||||
|
self, request: pb.OpenSessionRequest, context: object
|
||||||
|
) -> pb.OpenSessionReply:
|
||||||
|
return pb.OpenSessionReply(session_id="tls-session-1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def tls_server() -> AsyncIterator[int]:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cert_path, key_path = _generate_self_signed_cert(Path(tmp))
|
||||||
|
credentials = grpc.ssl_server_credentials(
|
||||||
|
[(key_path.read_bytes(), cert_path.read_bytes())]
|
||||||
|
)
|
||||||
|
server = grpc.aio.server()
|
||||||
|
pb_grpc.add_MxAccessGatewayServicer_to_server(_StaticGatewayServicer(), server)
|
||||||
|
port = _free_port()
|
||||||
|
server.add_secure_port(f"127.0.0.1:{port}", credentials)
|
||||||
|
await server.start()
|
||||||
|
try:
|
||||||
|
yield port
|
||||||
|
finally:
|
||||||
|
await server.stop(grace=None)
|
||||||
|
|
||||||
|
|
||||||
|
@requires_tls
|
||||||
|
@requires_openssl
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_tls_connects_via_tofu(tls_server: int) -> None:
|
||||||
|
"""Default TLS options (no CA) connect by pinning the presented cert."""
|
||||||
|
options = ClientOptions(
|
||||||
|
endpoint=f"127.0.0.1:{tls_server}",
|
||||||
|
api_key="mxgw_test_secret",
|
||||||
|
)
|
||||||
|
channel = create_channel(options)
|
||||||
|
try:
|
||||||
|
stub = pb_grpc.MxAccessGatewayStub(channel)
|
||||||
|
reply = await stub.OpenSession(pb.OpenSessionRequest(), timeout=10)
|
||||||
|
assert reply.session_id == "tls-session-1"
|
||||||
|
finally:
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_authority_parses_host_and_port() -> None:
|
||||||
|
from zb_mom_ww_mxgateway.options import _split_authority
|
||||||
|
|
||||||
|
assert _split_authority("https://10.0.0.5:5120") == ("10.0.0.5", 5120)
|
||||||
|
assert _split_authority("localhost:5120") == ("localhost", 5120)
|
||||||
|
assert _split_authority(":5120") == ("localhost", 5120)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_authority_strips_ipv6_brackets() -> None:
|
||||||
|
from zb_mom_ww_mxgateway.options import _split_authority
|
||||||
|
|
||||||
|
# Bracketed IPv6 with port — brackets must be removed for ssl.get_server_certificate
|
||||||
|
assert _split_authority("[::1]:5120") == ("::1", 5120)
|
||||||
|
# Bare bracketed IPv6 (no port) — default port 443
|
||||||
|
assert _split_authority("[::1]") == ("::1", 443)
|
||||||
|
# Scheme-prefixed bracketed IPv6
|
||||||
|
assert _split_authority("grpc://[::1]:5120") == ("::1", 5120)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tofu_connect_failure_raises_transport_error() -> None:
|
||||||
|
"""A failed cert pre-fetch surfaces the client's transport error type."""
|
||||||
|
options = ClientOptions(endpoint=f"127.0.0.1:{_free_port()}")
|
||||||
|
with pytest.raises(MxGatewayTransportError) as excinfo:
|
||||||
|
create_channel(options)
|
||||||
|
assert options.endpoint in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_certificate_validation_uses_system_trust() -> None:
|
||||||
|
"""``require_certificate_validation`` must not attempt a TOFU pre-fetch."""
|
||||||
|
# Pointing at a closed port: with system-trust the channel is created lazily
|
||||||
|
# (no eager pre-fetch), so create_channel must succeed without connecting.
|
||||||
|
options = ClientOptions(
|
||||||
|
endpoint=f"127.0.0.1:{_free_port()}",
|
||||||
|
require_certificate_validation=True,
|
||||||
|
)
|
||||||
|
channel = create_channel(options)
|
||||||
|
assert isinstance(channel, grpc.aio.Channel)
|
||||||
@@ -17,3 +17,6 @@
|
|||||||
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
||||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||||
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
||||||
|
|
||||||
|
[registries.dohertj2-gitea]
|
||||||
|
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||||
|
|||||||
+14
-2
@@ -2,7 +2,16 @@
|
|||||||
name = "zb-mom-ww-mxgateway-client"
|
name = "zb-mom-ww-mxgateway-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
authors = ["Joseph Doherty"]
|
||||||
|
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
|
||||||
|
license = "Proprietary"
|
||||||
|
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
documentation = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||||
|
categories = ["api-bindings", "asynchronous"]
|
||||||
|
publish = ["dohertj2-gitea"]
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
@@ -12,7 +21,10 @@ resolver = "2"
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
publish = false
|
authors = ["Joseph Doherty"]
|
||||||
|
license = "Proprietary"
|
||||||
|
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||||
|
publish = ["dohertj2-gitea"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
|
|||||||
+83
-2
@@ -62,8 +62,8 @@ cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-r
|
|||||||
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
|
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
|
||||||
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
|
||||||
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
|
||||||
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 1 --json
|
cargo run -p mxgw-cli -- stream-alarms --max-events 1 --json
|
||||||
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
cargo run -p mxgw-cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
|
||||||
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,6 +76,19 @@ types.
|
|||||||
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TLS trust (pin-only)
|
||||||
|
|
||||||
|
The gateway can auto-generate its own self-signed certificate (it has no PKI).
|
||||||
|
Unlike the other clients, the Rust client is **not** lenient: tonic 0.13.1
|
||||||
|
exposes no public hook to inject a custom certificate verifier, so TLS over Rust
|
||||||
|
is pin-only. A TLS connection requires either `--ca-file` /
|
||||||
|
`ClientOptions::with_ca_file(...)` to pin a CA (export the gateway's self-signed
|
||||||
|
certificate and pin it), or `--require-certificate-validation` /
|
||||||
|
`with_require_certificate_validation(true)` to verify against the system trust
|
||||||
|
roots. TLS with neither set fails `connect` with a clear, actionable error rather
|
||||||
|
than accepting the certificate. See
|
||||||
|
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
|
||||||
|
|
||||||
## Library Surface
|
## Library Surface
|
||||||
|
|
||||||
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
`ClientOptions` configures endpoint, API key, plaintext or TLS transport,
|
||||||
@@ -138,6 +151,50 @@ cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:500
|
|||||||
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Browsing lazily
|
||||||
|
|
||||||
|
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||||
|
time instead of paging the full hierarchy. Pass a default request for root
|
||||||
|
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
|
||||||
|
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
|
||||||
|
pairs `children` with `child_has_children` so you know which nodes to expand. See
|
||||||
|
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||||
|
request and filter semantics.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
|
||||||
|
|
||||||
|
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
|
||||||
|
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
|
||||||
|
println!("{} expand={}", child.tag_name, has_children);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### High-level walker
|
||||||
|
|
||||||
|
For UI trees, the client provides a `LazyBrowseNode` walker that handles
|
||||||
|
sibling pagination and the `child_has_children` hint for you:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut client = GalaxyClient::connect(
|
||||||
|
ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
|
||||||
|
).await?;
|
||||||
|
let roots = client.browse(None).await?;
|
||||||
|
for root in &roots {
|
||||||
|
if root.has_children_hint() {
|
||||||
|
root.expand().await?;
|
||||||
|
}
|
||||||
|
for child in root.children().await {
|
||||||
|
let kind = if child.has_children_hint() { "has children" } else { "leaf" };
|
||||||
|
println!("{} ({kind})", child.object().tag_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expand` is idempotent — calling it twice fires only one RPC,
|
||||||
|
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
|
||||||
|
`browse` again from the root.
|
||||||
|
|
||||||
### Watching deploy events
|
### Watching deploy events
|
||||||
|
|
||||||
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
||||||
@@ -192,3 +249,27 @@ cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --
|
|||||||
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||||
- [Rust Client Detailed Design](./RustClientDesign.md)
|
- [Rust Client Detailed Design](./RustClientDesign.md)
|
||||||
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||||
|
|
||||||
|
## Installing from the Gitea Cargo registry
|
||||||
|
|
||||||
|
The crate publishes to the internal Gitea Cargo registry. Register the
|
||||||
|
registry once in your global `~/.cargo/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[registries.dohertj2-gitea]
|
||||||
|
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[registries.dohertj2-gitea]
|
||||||
|
token = "Bearer <your-gitea-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the dependency:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
zb-mom-ww-mxgateway-client = { version = "0.1.0", registry = "dohertj2-gitea" }
|
||||||
|
```
|
||||||
|
|||||||
@@ -189,6 +189,25 @@ Support:
|
|||||||
- custom CA file,
|
- custom CA file,
|
||||||
- domain override.
|
- domain override.
|
||||||
|
|
||||||
|
### Trust posture (pin-only)
|
||||||
|
|
||||||
|
The gateway can serve a self-signed certificate it generates itself (it has no
|
||||||
|
PKI). Rust is the **exception** to the lenient-by-default posture the other
|
||||||
|
clients use: tonic 0.13.1 exposes no public hook to inject a custom certificate
|
||||||
|
verifier, so the Rust client cannot accept an arbitrary certificate. TLS over the
|
||||||
|
Rust client is therefore **pin-only** — it requires either:
|
||||||
|
|
||||||
|
- `ClientOptions::with_ca_file(...)` to pin a CA (the supported path for the
|
||||||
|
gateway's self-signed certificate; export the certificate and pin it), or
|
||||||
|
- `ClientOptions::with_require_certificate_validation(true)` to verify against the
|
||||||
|
system trust roots.
|
||||||
|
|
||||||
|
With TLS enabled (`with_plaintext(false)`), no pinned CA, and certificate
|
||||||
|
validation not required, `GatewayClient::connect` rejects the connection with a
|
||||||
|
clear, actionable error pointing at `with_ca_file` /
|
||||||
|
`require_certificate_validation` rather than silently accepting the certificate.
|
||||||
|
The CLI exposes `--ca-file` and `--require-certificate-validation`.
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
Expose event streams as a `Stream<Item = Result<MxEvent, Error>>`. Dropping the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name = "mxgw-cli"
|
name = "mxgw-cli"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
publish.workspace = true
|
publish = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "mxgw"
|
name = "mxgw"
|
||||||
|
|||||||
@@ -426,6 +426,11 @@ struct ConnectionArgs {
|
|||||||
ca_file: Option<PathBuf>,
|
ca_file: Option<PathBuf>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
server_name_override: Option<String>,
|
server_name_override: Option<String>,
|
||||||
|
/// Verify the server certificate against the system trust roots even
|
||||||
|
/// without a pinned CA. The Rust client's default is to require a CA
|
||||||
|
/// file (see `--ca-file`); set this flag to use system roots instead.
|
||||||
|
#[arg(long)]
|
||||||
|
require_certificate_validation: bool,
|
||||||
#[arg(long, default_value_t = 10)]
|
#[arg(long, default_value_t = 10)]
|
||||||
connect_timeout_seconds: u64,
|
connect_timeout_seconds: u64,
|
||||||
#[arg(long, default_value_t = 30)]
|
#[arg(long, default_value_t = 30)]
|
||||||
@@ -453,6 +458,9 @@ impl ConnectionArgs {
|
|||||||
if let Some(server_name_override) = &self.server_name_override {
|
if let Some(server_name_override) = &self.server_name_override {
|
||||||
options = options.with_server_name_override(server_name_override);
|
options = options.with_server_name_override(server_name_override);
|
||||||
}
|
}
|
||||||
|
if self.require_certificate_validation {
|
||||||
|
options = options.with_require_certificate_validation(true);
|
||||||
|
}
|
||||||
|
|
||||||
options
|
options
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
|
//! code should prefer [`GatewayClient::open_session`] and the [`Session`]
|
||||||
//! handle it returns, rather than the `*_raw` methods.
|
//! handle it returns, rather than the `*_raw` methods.
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
use tonic::codegen::InterceptedService;
|
use tonic::codegen::InterceptedService;
|
||||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
use tonic::transport::Channel;
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
|
|
||||||
use crate::auth::AuthInterceptor;
|
use crate::auth::AuthInterceptor;
|
||||||
@@ -21,7 +19,7 @@ use crate::generated::mxaccess_gateway::v1::{
|
|||||||
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest,
|
||||||
StreamEventsRequest,
|
StreamEventsRequest,
|
||||||
};
|
};
|
||||||
use crate::options::ClientOptions;
|
use crate::options::{build_tls_config, ClientOptions};
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
/// Generated gateway client wrapped in the auth interceptor that
|
/// Generated gateway client wrapped in the auth interceptor that
|
||||||
@@ -78,18 +76,7 @@ impl GatewayClient {
|
|||||||
})?;
|
})?;
|
||||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||||
|
|
||||||
if !options.plaintext() {
|
if let Some(tls) = build_tls_config(&options)? {
|
||||||
let mut tls = ClientTlsConfig::new();
|
|
||||||
if let Some(server_name) = options.server_name_override() {
|
|
||||||
tls = tls.domain_name(server_name.to_owned());
|
|
||||||
}
|
|
||||||
if let Some(ca_file) = options.ca_file() {
|
|
||||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
|
||||||
endpoint: options.endpoint().to_owned(),
|
|
||||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
|
||||||
})?;
|
|
||||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
|
||||||
}
|
|
||||||
endpoint = endpoint.tls_config(tls)?;
|
endpoint = endpoint.tls_config(tls)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+539
-20
@@ -5,23 +5,143 @@
|
|||||||
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
|
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
|
||||||
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
||||||
|
|
||||||
use std::fs;
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use prost_types::Timestamp;
|
use prost_types::Timestamp;
|
||||||
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
use tonic::codegen::InterceptedService;
|
use tonic::codegen::InterceptedService;
|
||||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
use tonic::transport::Channel;
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
|
|
||||||
use crate::auth::AuthInterceptor;
|
use crate::auth::AuthInterceptor;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
|
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
|
||||||
use crate::generated::galaxy_repository::v1::{
|
use crate::generated::galaxy_repository::v1::{
|
||||||
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest,
|
browse_children_request, BrowseChildrenReply, BrowseChildrenRequest, DeployEvent,
|
||||||
TestConnectionRequest, WatchDeployEventsRequest,
|
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
|
||||||
|
WatchDeployEventsRequest,
|
||||||
};
|
};
|
||||||
use crate::options::ClientOptions;
|
use crate::options::{build_tls_config, ClientOptions};
|
||||||
|
|
||||||
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
||||||
|
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
|
||||||
|
|
||||||
|
/// Optional filter set forwarded to `GalaxyRepository.BrowseChildren`.
|
||||||
|
///
|
||||||
|
/// Mirrors the request-level filters on the wire: combined with AND so a child
|
||||||
|
/// only appears when it satisfies every populated criterion. Construct via
|
||||||
|
/// [`BrowseChildrenOptions::default`] and tweak the fields you care about.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct BrowseChildrenOptions {
|
||||||
|
/// Restrict to objects whose `category_id` matches one of the supplied
|
||||||
|
/// Galaxy category identifiers. Empty means "no restriction".
|
||||||
|
pub category_ids: Vec<i32>,
|
||||||
|
/// Restrict to objects whose template chain contains every supplied
|
||||||
|
/// template name (case-sensitive substring match on each entry).
|
||||||
|
pub template_chain_contains: Vec<String>,
|
||||||
|
/// Restrict to objects whose tag name matches the supplied glob (SQL
|
||||||
|
/// `LIKE`-style on the server). `None` means "no glob filter".
|
||||||
|
pub tag_name_glob: Option<String>,
|
||||||
|
/// Optional tri-state hint for whether to populate `GalaxyObject.attributes`
|
||||||
|
/// on returned children. `None` falls back to the server default.
|
||||||
|
pub include_attributes: Option<bool>,
|
||||||
|
/// When `true`, only return children that own at least one alarm-bearing
|
||||||
|
/// attribute (matches `DiscoverHierarchy` semantics).
|
||||||
|
pub alarm_bearing_only: bool,
|
||||||
|
/// When `true`, only return children that own at least one historized
|
||||||
|
/// attribute (matches `DiscoverHierarchy` semantics).
|
||||||
|
pub historized_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lazy hierarchy node used by the walker built on top of `BrowseChildren`.
|
||||||
|
///
|
||||||
|
/// A node owns its [`GalaxyObject`], a hint as to whether the server believes
|
||||||
|
/// it has at least one matching descendant under the active filter set, and an
|
||||||
|
/// internal `expanded` flag protected by an async mutex. Calling [`expand`]
|
||||||
|
/// the first time issues a paged `BrowseChildren` RPC; subsequent calls are
|
||||||
|
/// no-ops so callers can poll without re-hitting the server.
|
||||||
|
///
|
||||||
|
/// `LazyBrowseNode` is cheap to clone — clones share state through an
|
||||||
|
/// internal `Arc`, so expanding one clone makes the children visible to every
|
||||||
|
/// other clone.
|
||||||
|
///
|
||||||
|
/// [`expand`]: LazyBrowseNode::expand
|
||||||
|
pub struct LazyBrowseNode {
|
||||||
|
inner: Arc<LazyBrowseNodeInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for LazyBrowseNode {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::clone(&self.inner),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LazyBrowseNodeInner {
|
||||||
|
client: GalaxyClient,
|
||||||
|
object: GalaxyObject,
|
||||||
|
has_children_hint: bool,
|
||||||
|
options: BrowseChildrenOptions,
|
||||||
|
state: AsyncMutex<LazyBrowseNodeState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LazyBrowseNodeState {
|
||||||
|
children: Vec<LazyBrowseNode>,
|
||||||
|
is_expanded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LazyBrowseNode {
|
||||||
|
/// Borrow the [`GalaxyObject`] returned by the server for this node.
|
||||||
|
pub fn object(&self) -> &GalaxyObject {
|
||||||
|
&self.inner.object
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-supplied hint: `true` when the child likely has at least one
|
||||||
|
/// further matching descendant. Useful to decide whether a UI should draw
|
||||||
|
/// an expand triangle without issuing the RPC up front.
|
||||||
|
pub fn has_children_hint(&self) -> bool {
|
||||||
|
self.inner.has_children_hint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the currently-known children. Empty until [`expand`] has
|
||||||
|
/// run at least once.
|
||||||
|
///
|
||||||
|
/// [`expand`]: LazyBrowseNode::expand
|
||||||
|
pub async fn children(&self) -> Vec<LazyBrowseNode> {
|
||||||
|
self.inner.state.lock().await.children.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` once [`expand`] has populated this node's children.
|
||||||
|
///
|
||||||
|
/// [`expand`]: LazyBrowseNode::expand
|
||||||
|
pub async fn is_expanded(&self) -> bool {
|
||||||
|
self.inner.state.lock().await.is_expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populate this node's children by issuing a paged `BrowseChildren` RPC.
|
||||||
|
/// Subsequent calls are no-ops — the cached children stay in place and no
|
||||||
|
/// additional RPC is issued.
|
||||||
|
pub async fn expand(&self) -> Result<(), Error> {
|
||||||
|
let mut state = self.inner.state.lock().await;
|
||||||
|
if state.is_expanded {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut client = self.inner.client.clone();
|
||||||
|
let new_children = client
|
||||||
|
.browse_children_inner(
|
||||||
|
Some(self.inner.object.gobject_id),
|
||||||
|
self.inner.options.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state.children = new_children;
|
||||||
|
state.is_expanded = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience alias for the generated Galaxy client wrapped in the
|
/// Convenience alias for the generated Galaxy client wrapped in the
|
||||||
/// authentication interceptor.
|
/// authentication interceptor.
|
||||||
@@ -62,18 +182,7 @@ impl GalaxyClient {
|
|||||||
})?;
|
})?;
|
||||||
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
endpoint = endpoint.connect_timeout(options.connect_timeout());
|
||||||
|
|
||||||
if !options.plaintext() {
|
if let Some(tls) = build_tls_config(&options)? {
|
||||||
let mut tls = ClientTlsConfig::new();
|
|
||||||
if let Some(server_name) = options.server_name_override() {
|
|
||||||
tls = tls.domain_name(server_name.to_owned());
|
|
||||||
}
|
|
||||||
if let Some(ca_file) = options.ca_file() {
|
|
||||||
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
|
||||||
endpoint: options.endpoint().to_owned(),
|
|
||||||
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
|
||||||
})?;
|
|
||||||
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
|
||||||
}
|
|
||||||
endpoint = endpoint.tls_config(tls)?;
|
endpoint = endpoint.tls_config(tls)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +281,99 @@ impl GalaxyClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Browse the top-level (root) objects of the hierarchy as
|
||||||
|
/// [`LazyBrowseNode`] instances. Pass [`BrowseChildrenOptions`] to
|
||||||
|
/// restrict the result set; the same filter is reused when callers expand
|
||||||
|
/// any returned node.
|
||||||
|
pub async fn browse(
|
||||||
|
&mut self,
|
||||||
|
options: Option<BrowseChildrenOptions>,
|
||||||
|
) -> Result<Vec<LazyBrowseNode>, Error> {
|
||||||
|
let effective = options.unwrap_or_default();
|
||||||
|
self.browse_children_inner(None, effective).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a single `BrowseChildren` RPC and return the raw reply. Callers
|
||||||
|
/// that want to drive paging themselves (or inspect the cache sequence)
|
||||||
|
/// use this; high-level walking goes through [`browse`] and
|
||||||
|
/// [`LazyBrowseNode::expand`].
|
||||||
|
///
|
||||||
|
/// [`browse`]: GalaxyClient::browse
|
||||||
|
pub async fn browse_children_raw(
|
||||||
|
&mut self,
|
||||||
|
request: BrowseChildrenRequest,
|
||||||
|
) -> Result<BrowseChildrenReply, Error> {
|
||||||
|
let response = self
|
||||||
|
.inner
|
||||||
|
.browse_children(self.unary_request(request))
|
||||||
|
.await?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn browse_children_inner(
|
||||||
|
&mut self,
|
||||||
|
parent_gobject_id: Option<i32>,
|
||||||
|
options: BrowseChildrenOptions,
|
||||||
|
) -> Result<Vec<LazyBrowseNode>, Error> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
let mut page_token = String::new();
|
||||||
|
let mut seen_page_tokens: HashSet<String> = HashSet::new();
|
||||||
|
loop {
|
||||||
|
let parent = parent_gobject_id.map(browse_children_request::Parent::ParentGobjectId);
|
||||||
|
let request = BrowseChildrenRequest {
|
||||||
|
page_size: BROWSE_CHILDREN_PAGE_SIZE,
|
||||||
|
page_token: page_token.clone(),
|
||||||
|
category_ids: options.category_ids.clone(),
|
||||||
|
template_chain_contains: options.template_chain_contains.clone(),
|
||||||
|
tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
|
||||||
|
include_attributes: options.include_attributes,
|
||||||
|
alarm_bearing_only: options.alarm_bearing_only,
|
||||||
|
historized_only: options.historized_only,
|
||||||
|
parent,
|
||||||
|
};
|
||||||
|
|
||||||
|
let reply = self.browse_children_raw(request).await?;
|
||||||
|
let hints = reply.child_has_children;
|
||||||
|
for (index, object) in reply.children.into_iter().enumerate() {
|
||||||
|
let hint = hints.get(index).copied().unwrap_or(false);
|
||||||
|
nodes.push(self.make_lazy_node(object, hint, options.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
page_token = reply.next_page_token;
|
||||||
|
if page_token.is_empty() {
|
||||||
|
return Ok(nodes);
|
||||||
|
}
|
||||||
|
if !seen_page_tokens.insert(page_token.clone()) {
|
||||||
|
return Err(Error::InvalidArgument {
|
||||||
|
name: "page_token".to_owned(),
|
||||||
|
detail: format!(
|
||||||
|
"galaxy browse children returned repeated page token `{page_token}`"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_lazy_node(
|
||||||
|
&self,
|
||||||
|
object: GalaxyObject,
|
||||||
|
has_children_hint: bool,
|
||||||
|
options: BrowseChildrenOptions,
|
||||||
|
) -> LazyBrowseNode {
|
||||||
|
LazyBrowseNode {
|
||||||
|
inner: Arc::new(LazyBrowseNodeInner {
|
||||||
|
client: self.clone(),
|
||||||
|
object,
|
||||||
|
has_children_hint,
|
||||||
|
options,
|
||||||
|
state: AsyncMutex::new(LazyBrowseNodeState {
|
||||||
|
children: Vec::new(),
|
||||||
|
is_expanded: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to the server-streamed deploy-event feed.
|
/// Subscribe to the server-streamed deploy-event feed.
|
||||||
///
|
///
|
||||||
/// The server emits a bootstrap event describing the current cache state
|
/// The server emits a bootstrap event describing the current cache state
|
||||||
@@ -234,9 +436,10 @@ mod tests {
|
|||||||
GalaxyRepository, GalaxyRepositoryServer,
|
GalaxyRepository, GalaxyRepositoryServer,
|
||||||
};
|
};
|
||||||
use crate::generated::galaxy_repository::v1::{
|
use crate::generated::galaxy_repository::v1::{
|
||||||
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute,
|
BrowseChildrenReply, BrowseChildrenRequest, DeployEvent, DiscoverHierarchyReply,
|
||||||
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply,
|
DiscoverHierarchyRequest, GalaxyAttribute, GalaxyObject, GetLastDeployTimeReply,
|
||||||
TestConnectionRequest, WatchDeployEventsRequest,
|
GetLastDeployTimeRequest, TestConnectionReply, TestConnectionRequest,
|
||||||
|
WatchDeployEventsRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
|
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
|
||||||
@@ -249,6 +452,9 @@ mod tests {
|
|||||||
objects: Mutex<Vec<GalaxyObject>>,
|
objects: Mutex<Vec<GalaxyObject>>,
|
||||||
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
|
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
|
||||||
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
|
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
|
||||||
|
browse_children_calls: Mutex<Vec<BrowseChildrenRequest>>,
|
||||||
|
browse_children_replies: Mutex<std::collections::VecDeque<BrowseChildrenReply>>,
|
||||||
|
browse_children_errors: Mutex<Vec<Status>>,
|
||||||
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
|
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
|
||||||
watch_events: Mutex<Vec<DeployEvent>>,
|
watch_events: Mutex<Vec<DeployEvent>>,
|
||||||
watch_senders: Mutex<Vec<DeployEventTx>>,
|
watch_senders: Mutex<Vec<DeployEventTx>>,
|
||||||
@@ -306,6 +512,28 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn browse_children(
|
||||||
|
&self,
|
||||||
|
request: Request<BrowseChildrenRequest>,
|
||||||
|
) -> Result<Response<BrowseChildrenReply>, Status> {
|
||||||
|
self.state
|
||||||
|
.browse_children_calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.into_inner());
|
||||||
|
if let Some(error) = self.state.browse_children_errors.lock().unwrap().pop() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let reply = self
|
||||||
|
.state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(Response::new(reply))
|
||||||
|
}
|
||||||
|
|
||||||
type WatchDeployEventsStream =
|
type WatchDeployEventsStream =
|
||||||
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
|
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
|
||||||
|
|
||||||
@@ -695,4 +923,295 @@ mod tests {
|
|||||||
"drop signal channel closed unexpectedly"
|
"drop signal channel closed unexpectedly"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn browse_obj(gid: i32, tag: &str, is_area: bool) -> GalaxyObject {
|
||||||
|
GalaxyObject {
|
||||||
|
gobject_id: gid,
|
||||||
|
tag_name: tag.to_owned(),
|
||||||
|
contained_name: String::new(),
|
||||||
|
browse_name: tag.to_owned(),
|
||||||
|
parent_gobject_id: 0,
|
||||||
|
is_area,
|
||||||
|
category_id: 0,
|
||||||
|
hosted_by_gobject_id: 0,
|
||||||
|
template_chain: Vec::new(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_browse_reply(
|
||||||
|
children: Vec<GalaxyObject>,
|
||||||
|
child_has_children: Vec<bool>,
|
||||||
|
cache_sequence: u64,
|
||||||
|
) -> BrowseChildrenReply {
|
||||||
|
BrowseChildrenReply {
|
||||||
|
total_child_count: children.len() as i32,
|
||||||
|
cache_sequence,
|
||||||
|
children,
|
||||||
|
child_has_children,
|
||||||
|
next_page_token: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browse_no_parent_returns_roots() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(1, "Area_A", true), browse_obj(2, "Area_B", true)],
|
||||||
|
vec![true, false],
|
||||||
|
7,
|
||||||
|
));
|
||||||
|
let endpoint = spawn_fake(state.clone()).await;
|
||||||
|
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let roots = client.browse(None).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(roots.len(), 2);
|
||||||
|
assert_eq!(roots[0].object().tag_name, "Area_A");
|
||||||
|
assert!(roots[0].has_children_hint());
|
||||||
|
assert_eq!(roots[1].object().tag_name, "Area_B");
|
||||||
|
assert!(!roots[1].has_children_hint());
|
||||||
|
|
||||||
|
let calls = state.browse_children_calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert!(
|
||||||
|
calls[0].parent.is_none(),
|
||||||
|
"root browse must send an empty parent oneof, got {:?}",
|
||||||
|
calls[0].parent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browse_expand_populates_children_and_marks_expanded() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
// First call: roots.
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(10, "Area_A", true)],
|
||||||
|
vec![true],
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
// Second call: children of gobject 10.
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(11, "Receiver_1", false)],
|
||||||
|
vec![false],
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let endpoint = spawn_fake(state.clone()).await;
|
||||||
|
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let roots = client.browse(None).await.unwrap();
|
||||||
|
let root = roots.into_iter().next().expect("at least one root");
|
||||||
|
assert!(!root.is_expanded().await);
|
||||||
|
|
||||||
|
root.expand().await.unwrap();
|
||||||
|
|
||||||
|
assert!(root.is_expanded().await);
|
||||||
|
let children = root.children().await;
|
||||||
|
assert_eq!(children.len(), 1);
|
||||||
|
assert_eq!(children[0].object().tag_name, "Receiver_1");
|
||||||
|
|
||||||
|
let calls = state.browse_children_calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 2);
|
||||||
|
let expand_call = &calls[1];
|
||||||
|
match expand_call.parent.as_ref().expect("expand sends parent") {
|
||||||
|
browse_children_request::Parent::ParentGobjectId(id) => assert_eq!(*id, 10),
|
||||||
|
other => panic!("expected ParentGobjectId variant, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browse_expand_idempotent_no_second_rpc() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(20, "Area_X", true)],
|
||||||
|
vec![true],
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(21, "Leaf", false)],
|
||||||
|
vec![false],
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let endpoint = spawn_fake(state.clone()).await;
|
||||||
|
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let roots = client.browse(None).await.unwrap();
|
||||||
|
let root = roots.into_iter().next().unwrap();
|
||||||
|
root.expand().await.unwrap();
|
||||||
|
let after_first = state.browse_children_calls.lock().unwrap().len();
|
||||||
|
|
||||||
|
// Calling expand a second time must NOT issue a new RPC.
|
||||||
|
root.expand().await.unwrap();
|
||||||
|
|
||||||
|
let after_second = state.browse_children_calls.lock().unwrap().len();
|
||||||
|
assert_eq!(
|
||||||
|
after_first, after_second,
|
||||||
|
"expand should be idempotent — no extra RPC the second time"
|
||||||
|
);
|
||||||
|
assert_eq!(root.children().await.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browse_expand_unknown_parent_returns_not_found_error() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
// Root browse succeeds.
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(99, "GhostArea", true)],
|
||||||
|
vec![true],
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let endpoint = spawn_fake(state.clone()).await;
|
||||||
|
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let roots = client.browse(None).await.unwrap();
|
||||||
|
let root = roots.into_iter().next().unwrap();
|
||||||
|
|
||||||
|
// Seed the NotFound only AFTER the root call so the FakeGalaxy's
|
||||||
|
// error stack doesn't intercept the initial browse.
|
||||||
|
state
|
||||||
|
.browse_children_errors
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(Status::not_found("parent gobject 99 not present in cache"));
|
||||||
|
|
||||||
|
let error = root.expand().await.unwrap_err();
|
||||||
|
|
||||||
|
match &error {
|
||||||
|
Error::Status(status) => {
|
||||||
|
assert_eq!(status.code(), tonic::Code::NotFound);
|
||||||
|
}
|
||||||
|
other => panic!("expected Error::Status(NotFound), got {other:?}"),
|
||||||
|
}
|
||||||
|
// Failed expand must NOT mark the node as expanded — caller can retry.
|
||||||
|
assert!(!root.is_expanded().await);
|
||||||
|
assert!(root.children().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browse_expand_multi_page_gathers_all_pages() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
// First reply: roots.
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(30, "Plant", true)],
|
||||||
|
vec![true],
|
||||||
|
5,
|
||||||
|
));
|
||||||
|
// Second reply: page 1 of children, with a next_page_token.
|
||||||
|
let mut page_one = build_browse_reply(
|
||||||
|
vec![
|
||||||
|
browse_obj(31, "Child_A", false),
|
||||||
|
browse_obj(32, "Child_B", false),
|
||||||
|
],
|
||||||
|
vec![false, false],
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
page_one.next_page_token = "cursor-2".to_owned();
|
||||||
|
page_one.total_child_count = 3;
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(page_one);
|
||||||
|
// Third reply: page 2 of children, with no next page.
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(
|
||||||
|
vec![browse_obj(33, "Child_C", false)],
|
||||||
|
vec![false],
|
||||||
|
5,
|
||||||
|
));
|
||||||
|
let endpoint = spawn_fake(state.clone()).await;
|
||||||
|
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let roots = client.browse(None).await.unwrap();
|
||||||
|
let root = roots.into_iter().next().unwrap();
|
||||||
|
root.expand().await.unwrap();
|
||||||
|
|
||||||
|
let children = root.children().await;
|
||||||
|
assert_eq!(children.len(), 3);
|
||||||
|
assert_eq!(children[0].object().tag_name, "Child_A");
|
||||||
|
assert_eq!(children[1].object().tag_name, "Child_B");
|
||||||
|
assert_eq!(children[2].object().tag_name, "Child_C");
|
||||||
|
|
||||||
|
let calls = state.browse_children_calls.lock().unwrap();
|
||||||
|
// 1 root call + 2 paged expand calls = 3 total.
|
||||||
|
assert_eq!(calls.len(), 3);
|
||||||
|
assert_eq!(calls[1].page_token, "");
|
||||||
|
assert_eq!(calls[2].page_token, "cursor-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn browse_with_filter_forwards_to_request() {
|
||||||
|
let state = Arc::new(FakeState::default());
|
||||||
|
state
|
||||||
|
.browse_children_replies
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push_back(build_browse_reply(Vec::new(), Vec::new(), 1));
|
||||||
|
let endpoint = spawn_fake(state.clone()).await;
|
||||||
|
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let options = BrowseChildrenOptions {
|
||||||
|
category_ids: vec![3, 5],
|
||||||
|
template_chain_contains: vec!["$DelmiaReceiver".to_owned()],
|
||||||
|
tag_name_glob: Some("Recv_*".to_owned()),
|
||||||
|
include_attributes: Some(true),
|
||||||
|
alarm_bearing_only: true,
|
||||||
|
historized_only: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = client.browse(Some(options)).await.unwrap();
|
||||||
|
|
||||||
|
let calls = state.browse_children_calls.lock().unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
let req = &calls[0];
|
||||||
|
assert_eq!(req.category_ids, vec![3, 5]);
|
||||||
|
assert_eq!(req.template_chain_contains, vec!["$DelmiaReceiver"]);
|
||||||
|
assert_eq!(req.tag_name_glob, "Recv_*");
|
||||||
|
assert_eq!(req.include_attributes, Some(true));
|
||||||
|
assert!(req.alarm_bearing_only);
|
||||||
|
assert!(!req.historized_only);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
//! chain of `with_*` setters; the `Debug` impl redacts the API key.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tonic::transport::{Certificate, ClientTlsConfig};
|
||||||
|
|
||||||
use crate::auth::ApiKey;
|
use crate::auth::ApiKey;
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -22,6 +26,7 @@ pub struct ClientOptions {
|
|||||||
api_key: Option<ApiKey>,
|
api_key: Option<ApiKey>,
|
||||||
plaintext: bool,
|
plaintext: bool,
|
||||||
ca_file: Option<PathBuf>,
|
ca_file: Option<PathBuf>,
|
||||||
|
require_certificate_validation: bool,
|
||||||
server_name_override: Option<String>,
|
server_name_override: Option<String>,
|
||||||
connect_timeout: Duration,
|
connect_timeout: Duration,
|
||||||
call_timeout: Duration,
|
call_timeout: Duration,
|
||||||
@@ -38,6 +43,7 @@ impl ClientOptions {
|
|||||||
api_key: None,
|
api_key: None,
|
||||||
plaintext: true,
|
plaintext: true,
|
||||||
ca_file: None,
|
ca_file: None,
|
||||||
|
require_certificate_validation: false,
|
||||||
server_name_override: None,
|
server_name_override: None,
|
||||||
connect_timeout: Duration::from_secs(10),
|
connect_timeout: Duration::from_secs(10),
|
||||||
call_timeout: Duration::from_secs(30),
|
call_timeout: Duration::from_secs(30),
|
||||||
@@ -67,6 +73,22 @@ impl ClientOptions {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Require TLS certificate verification even without a pinned CA. Default
|
||||||
|
/// false: the gateway's self-signed certificate is accepted (internal-tool
|
||||||
|
/// posture). Setting a CA file always verifies.
|
||||||
|
///
|
||||||
|
/// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a
|
||||||
|
/// custom rustls verifier, so the Rust client cannot accept an arbitrary
|
||||||
|
/// self-signed certificate the way the other clients do. With the default
|
||||||
|
/// (false) and no pinned CA, [`crate::client::GatewayClient::connect`]
|
||||||
|
/// rejects the TLS connection and asks for a CA file. Either pin a CA via
|
||||||
|
/// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or
|
||||||
|
/// set this `true` to verify against the system trust roots.
|
||||||
|
pub fn with_require_certificate_validation(mut self, require: bool) -> Self {
|
||||||
|
self.require_certificate_validation = require;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Override the SNI/server name used during the TLS handshake. Useful
|
/// Override the SNI/server name used during the TLS handshake. Useful
|
||||||
/// when the dial-target host name does not match the certificate.
|
/// when the dial-target host name does not match the certificate.
|
||||||
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
pub fn with_server_name_override(mut self, server_name_override: impl Into<String>) -> Self {
|
||||||
@@ -121,6 +143,12 @@ impl ClientOptions {
|
|||||||
self.ca_file.as_ref()
|
self.ca_file.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether TLS certificate verification is required even without a pinned
|
||||||
|
/// CA. See [`ClientOptions::with_require_certificate_validation`].
|
||||||
|
pub fn require_certificate_validation(&self) -> bool {
|
||||||
|
self.require_certificate_validation
|
||||||
|
}
|
||||||
|
|
||||||
/// Optional SNI / server-name override for TLS handshakes.
|
/// Optional SNI / server-name override for TLS handshakes.
|
||||||
pub fn server_name_override(&self) -> Option<&str> {
|
pub fn server_name_override(&self) -> Option<&str> {
|
||||||
self.server_name_override.as_deref()
|
self.server_name_override.as_deref()
|
||||||
@@ -147,6 +175,68 @@ impl ClientOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the [`ClientTlsConfig`] for a non-plaintext connection described by
|
||||||
|
/// `options`, applying the lenient-default guard that is the **Rust
|
||||||
|
/// pin-only exception**.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed).
|
||||||
|
/// Returns `Ok(Some(tls))` when a valid TLS config can be assembled.
|
||||||
|
/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned
|
||||||
|
/// CA was provided and `require_certificate_validation` is `false`.
|
||||||
|
///
|
||||||
|
/// # Why this guard exists
|
||||||
|
///
|
||||||
|
/// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||||
|
/// crate-private connector and exposes no hook for a custom
|
||||||
|
/// `ServerCertVerifier`. The Rust client therefore cannot accept an arbitrary
|
||||||
|
/// self-signed certificate the way the other language clients do. Rather than
|
||||||
|
/// silently falling back to system-root verification (which always fails
|
||||||
|
/// against a self-signed gateway certificate), we reject the configuration
|
||||||
|
/// early with an actionable error.
|
||||||
|
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
|
||||||
|
if options.plaintext() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tls = ClientTlsConfig::new();
|
||||||
|
if let Some(server_name) = options.server_name_override() {
|
||||||
|
tls = tls.domain_name(server_name.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(ca_file) = options.ca_file() {
|
||||||
|
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
|
||||||
|
endpoint: options.endpoint().to_owned(),
|
||||||
|
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
|
||||||
|
})?;
|
||||||
|
tls = tls.ca_certificate(Certificate::from_pem(certificate));
|
||||||
|
} else if !options.require_certificate_validation() {
|
||||||
|
// Lenient-default fallback (Rust pin-only exception): tonic
|
||||||
|
// 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
||||||
|
// crate-private connector and exposes no hook for a custom
|
||||||
|
// `ServerCertVerifier`, so — unlike the other clients — the
|
||||||
|
// Rust client cannot accept an arbitrary self-signed cert. Pin
|
||||||
|
// the gateway's CA instead, or opt into strict verification
|
||||||
|
// against the system trust roots. We reject here rather than
|
||||||
|
// silently verifying against system roots (which would fail a
|
||||||
|
// self-signed gateway with a confusing handshake error).
|
||||||
|
//
|
||||||
|
// Note: a server-name override affects SNI (the hostname sent
|
||||||
|
// in the TLS ClientHello) but does NOT pin trust. Overriding
|
||||||
|
// the server name alone does not bypass certificate validation.
|
||||||
|
return Err(Error::InvalidEndpoint {
|
||||||
|
endpoint: options.endpoint().to_owned(),
|
||||||
|
detail: "TLS requested without a pinned CA. The Rust client cannot accept an \
|
||||||
|
arbitrary self-signed certificate (tonic 0.13 exposes no custom \
|
||||||
|
rustls verifier). Pin the gateway certificate with \
|
||||||
|
ClientOptions::with_ca_file, or call \
|
||||||
|
ClientOptions::with_require_certificate_validation(true) to verify \
|
||||||
|
against the system trust roots. Note: a server-name override \
|
||||||
|
affects SNI but does not pin trust."
|
||||||
|
.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Some(tls))
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ClientOptions {
|
impl Default for ClientOptions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new("http://127.0.0.1:5000")
|
Self::new("http://127.0.0.1:5000")
|
||||||
@@ -161,6 +251,10 @@ impl fmt::Debug for ClientOptions {
|
|||||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||||
.field("plaintext", &self.plaintext)
|
.field("plaintext", &self.plaintext)
|
||||||
.field("ca_file", &self.ca_file)
|
.field("ca_file", &self.ca_file)
|
||||||
|
.field(
|
||||||
|
"require_certificate_validation",
|
||||||
|
&self.require_certificate_validation,
|
||||||
|
)
|
||||||
.field("server_name_override", &self.server_name_override)
|
.field("server_name_override", &self.server_name_override)
|
||||||
.field("connect_timeout", &self.connect_timeout)
|
.field("connect_timeout", &self.connect_timeout)
|
||||||
.field("call_timeout", &self.call_timeout)
|
.field("call_timeout", &self.call_timeout)
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! TLS posture coverage for the Rust client.
|
||||||
|
//!
|
||||||
|
//! tonic 0.13.1's `ClientTlsConfig` exposes no hook for a custom rustls
|
||||||
|
//! `ServerCertVerifier` (the verifier is built internally inside the
|
||||||
|
//! crate-private `TlsConnector`), so the Rust client cannot implement the
|
||||||
|
//! "accept any server certificate" lenient default the other clients use.
|
||||||
|
//! Rust is therefore the documented **pin-only exception**: TLS without a
|
||||||
|
//! pinned CA is rejected up front with a clear, actionable error, and
|
||||||
|
//! supplying a CA file is the supported path. These tests pin that contract.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use zb_mom_ww_mxgateway_client::{ClientOptions, Error, GalaxyClient, GatewayClient};
|
||||||
|
|
||||||
|
/// Drive `connect` to its error without requiring `GatewayClient: Debug`
|
||||||
|
/// (the success arm is dropped explicitly so `unwrap_err` is unnecessary).
|
||||||
|
async fn connect_err(options: ClientOptions) -> Error {
|
||||||
|
match GatewayClient::connect(options).await {
|
||||||
|
Ok(_client) => panic!("connect unexpectedly succeeded against a dead TLS address"),
|
||||||
|
Err(error) => error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = connect_err(options).await;
|
||||||
|
|
||||||
|
let Error::InvalidEndpoint { detail, .. } = error else {
|
||||||
|
panic!("expected InvalidEndpoint, got {error:?}");
|
||||||
|
};
|
||||||
|
// The message must point the caller at the supported remedy (pin a CA)
|
||||||
|
// and name the opt-in escape hatch.
|
||||||
|
assert!(
|
||||||
|
detail.contains("ca_file") || detail.contains("CA"),
|
||||||
|
"error should instruct the user to pass a CA file: {detail}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
detail.contains("require_certificate_validation"),
|
||||||
|
"error should mention the require_certificate_validation opt-in: {detail}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_with_require_certificate_validation_does_not_short_circuit() {
|
||||||
|
// With strict verification opted in, the no-CA guard must not fire; the
|
||||||
|
// connect attempt instead proceeds to the transport (and fails to reach
|
||||||
|
// the dead address) rather than returning the "CA required" guard error.
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_require_certificate_validation(true)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = connect_err(options).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!matches!(&error, Error::InvalidEndpoint { detail, .. }
|
||||||
|
if detail.contains("require_certificate_validation")),
|
||||||
|
"strict verification must bypass the no-CA guard, got {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tls_with_ca_file_is_permitted_and_proceeds_past_the_guard() {
|
||||||
|
// Pinning a CA is the supported TLS path: the no-CA guard must not fire.
|
||||||
|
// We hand it a readable PEM file; construction proceeds past the guard
|
||||||
|
// and only fails later at the transport (dead address / handshake).
|
||||||
|
let ca_path = std::env::temp_dir().join("mxgw-rust-tls-ca-fixture.pem");
|
||||||
|
std::fs::write(&ca_path, SELF_SIGNED_CA_PEM).unwrap();
|
||||||
|
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_ca_file(&ca_path)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = connect_err(options).await;
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&ca_path);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!matches!(&error, Error::InvalidEndpoint { detail, .. }
|
||||||
|
if detail.contains("require_certificate_validation")),
|
||||||
|
"pinning a CA must bypass the no-CA guard, got {error:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive `GalaxyClient::connect` to its error (mirrors `connect_err` above).
|
||||||
|
async fn galaxy_connect_err(options: ClientOptions) -> Error {
|
||||||
|
match GalaxyClient::connect(options).await {
|
||||||
|
Ok(_client) => {
|
||||||
|
panic!("GalaxyClient::connect unexpectedly succeeded against a dead TLS address")
|
||||||
|
}
|
||||||
|
Err(error) => error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn galaxy_tls_without_ca_is_rejected_with_actionable_error_by_default() {
|
||||||
|
// GalaxyClient::connect must apply the same TLS guard as GatewayClient —
|
||||||
|
// TLS without a pinned CA (and without require_certificate_validation)
|
||||||
|
// returns a clear, actionable InvalidEndpoint error.
|
||||||
|
let options = ClientOptions::new("https://127.0.0.1:1")
|
||||||
|
.with_plaintext(false)
|
||||||
|
.with_connect_timeout(Duration::from_millis(200));
|
||||||
|
|
||||||
|
let error = galaxy_connect_err(options).await;
|
||||||
|
|
||||||
|
let Error::InvalidEndpoint { detail, .. } = error else {
|
||||||
|
panic!("expected InvalidEndpoint, got {error:?}");
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
detail.contains("ca_file") || detail.contains("CA"),
|
||||||
|
"error should instruct the user to pass a CA file: {detail}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
detail.contains("require_certificate_validation"),
|
||||||
|
"error should mention the require_certificate_validation opt-in: {detail}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A throwaway self-signed CA certificate (PEM). Only needs to parse as a
|
||||||
|
/// PEM trust root so the CA-pinning path is exercised past the guard.
|
||||||
|
const SELF_SIGNED_CA_PEM: &str = "-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
|
||||||
|
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
|
||||||
|
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
|
||||||
|
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
|
||||||
|
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
|
||||||
|
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
|
||||||
|
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
|
||||||
|
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
|
||||||
|
6MF9+Yw1Yy0t
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
";
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-24 |
|
| Review date | 2026-05-24 |
|
||||||
| Commit reviewed | `42b0037` |
|
| Commit reviewed | `42b0037` |
|
||||||
| Status | Re-reviewed |
|
| Status | Re-reviewed |
|
||||||
| Open findings | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ Client.Java-001..031 are unchanged.
|
|||||||
| Severity | High |
|
| Severity | High |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Location | `clients/java/README.md:182-183` |
|
| Location | `clients/java/README.md:182-183` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Commit `8738735` ("clients: document StreamAlarms + AcknowledgeAlarm in each README") added two new gradle invocations to the CLI Usage block:
|
**Description:** Commit `8738735` ("clients: document StreamAlarms + AcknowledgeAlarm in each README") added two new gradle invocations to the CLI Usage block:
|
||||||
|
|
||||||
@@ -569,6 +569,8 @@ A user copying either invocation from the README hits a picocli parse error imme
|
|||||||
|
|
||||||
**Recommendation:** Drop the `--session-id <id>` token from both documented invocations, and change `--alarm-reference` to `--reference` in the `acknowledge-alarm` line. Optionally also add `--filter-prefix` to the `stream-alarms` example so readers see the scoping option, and align README option names with the actual CLI by either renaming the CLI option `--reference` → `--alarm-reference` (matches the proto `alarm_full_reference` field semantically) or leaving as is and only fixing the README. Add a small `MxGatewayCliTests` parse-only assertion for both subcommands that exercises every option flag to prevent the same drift the next time the CLI surface or README is touched.
|
**Recommendation:** Drop the `--session-id <id>` token from both documented invocations, and change `--alarm-reference` to `--reference` in the `acknowledge-alarm` line. Optionally also add `--filter-prefix` to the `stream-alarms` example so readers see the scoping option, and align README option names with the actual CLI by either renaming the CLI option `--reference` → `--alarm-reference` (matches the proto `alarm_full_reference` field semantically) or leaving as is and only fixing the README. Add a small `MxGatewayCliTests` parse-only assertion for both subcommands that exercises every option flag to prevent the same drift the next time the CLI surface or README is touched.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Confirmed root cause against `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1174-1182,1248-1258`: `StreamAlarmsCommand` exposes only `--filter-prefix` / `--limit` and `AcknowledgeAlarmCommand` exposes `--reference` / `--comment` / `--operator` — neither has a `--session-id` option and `acknowledge-alarm` has no `--alarm-reference` option, so both documented invocations failed picocli parse at the first unknown option. Fixed `clients/java/README.md:182-183` by dropping the `--session-id <id>` token from both lines, replacing it with `--filter-prefix Galaxy` on the `stream-alarms` example so readers see the actual scoping flag, and changing `--alarm-reference` to `--reference` on the `acknowledge-alarm` example. Added `MxGatewayCli.commandLine(...)` to package-private visibility (was `private`) so the test can drive the production picocli `CommandLine` directly without executing the command body. Regression tests in `MxGatewayCliTests`: `readmeDocumentedStreamAlarmsExampleParsesCleanly` and `readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` pin the exact token list documented in the README and assert `commandLine.parseArgs(...)` returns without throwing a `picocli.CommandLine.ParameterException`. TDD red phase: before the README fix the previously-documented tokens (`--session-id <id>` + `--alarm-reference ...`) would have thrown `Unknown option: '--session-id'` / `Unknown option: '--alarm-reference'` at parse time; the new tests pass against the corrected README and would fail the next time someone drifts the documented surface from the actual CLI options.
|
||||||
|
|
||||||
### Client.Java-033
|
### Client.Java-033
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -576,7 +578,7 @@ A user copying either invocation from the README hits a picocli parse error imme
|
|||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` |
|
| Location | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `StreamAlarmsCommand.call()` allocates a bounded `ArrayBlockingQueue<Object>(1024)` and the gRPC observer publishes each `AlarmFeedMessage` via `queue.offer(value)`:
|
**Description:** `StreamAlarmsCommand.call()` allocates a bounded `ArrayBlockingQueue<Object>(1024)` and the gRPC observer publishes each `AlarmFeedMessage` via `queue.offer(value)`:
|
||||||
|
|
||||||
@@ -594,6 +596,8 @@ The library-side `MxEventStream` (Client.Java-002 resolution) and `DeployEventSt
|
|||||||
|
|
||||||
**Recommendation:** Either (a) wrap the gRPC observer in the existing `MxEventStream`-style adaptor that calls `subscription.cancel()` and queues an exception on `queue.offer` returning `false`, then surface that exception from the drain loop — mirroring `MxEventStream.observer().onNext`'s overflow branch; or (b) reuse the library-side fail-fast plumbing by promoting `MxEventStream` (or extracting its terminal-state base) into a public `MxAlarmFeedStream` and have `MxGatewayClient.streamAlarms` return that instead of a bare subscription handle. Option (b) lines up with Client.Java-036 (deduplicate the subscription class family). Add a CLI regression test that overflows the bounded queue and asserts a non-zero exit / overflow exception, mirroring `MxGatewayMediumFindingsTests.eventStreamOverflowExceptionSurvivesASubsequentClose`.
|
**Recommendation:** Either (a) wrap the gRPC observer in the existing `MxEventStream`-style adaptor that calls `subscription.cancel()` and queues an exception on `queue.offer` returning `false`, then surface that exception from the drain loop — mirroring `MxEventStream.observer().onNext`'s overflow branch; or (b) reuse the library-side fail-fast plumbing by promoting `MxEventStream` (or extracting its terminal-state base) into a public `MxAlarmFeedStream` and have `MxGatewayClient.streamAlarms` return that instead of a bare subscription handle. Option (b) lines up with Client.Java-036 (deduplicate the subscription class family). Add a CLI regression test that overflows the bounded queue and asserts a non-zero exit / overflow exception, mirroring `MxGatewayMediumFindingsTests.eventStreamOverflowExceptionSurvivesASubsequentClose`.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Confirmed root cause at `MxGatewayCli.java` `StreamAlarmsCommand.call()`: the observer's `onNext` did `queue.offer(value)` and ignored the boolean return, so a 1024-element queue would silently drop messages past capacity. The same silent-drop affected the `onCompleted` branch (which `offer`s `ALARM_FEED_END`) once the queue was full, deadlocking the consumer since the drain loop never sees END. Took option (a) — minimal change that matches `MxEventStream`'s overflow branch. The fix: detect a failed `offer` inside `onNext`, call `subscription.cancel()` (via an `AtomicReference<MxGatewayAlarmFeedSubscription>` published immediately after `client.streamAlarms` returns), `queue.clear()`, then `queue.offer(IllegalStateException("stream-alarms queue overflowed (capacity 1024); consumer too slow"))` followed by `queue.offer(ALARM_FEED_END)`. The existing drain-loop `Throwable`-branch then surfaces the overflow as a thrown `IllegalStateException` from `call()`, which picocli reports as a non-zero CLI exit. Option (b) (promoting `MxEventStream` to a public alarm-feed stream) was considered and rejected for this change — it would change the public SDK surface; Client.Java-036's refactor handles deduplication at the subscription layer instead. Regression test: `MxGatewayCliTests.streamAlarmsCommandFailsFastOnQueueOverflow` — drives an `OverflowingFakeClient` whose `streamAlarms` synchronously pushes 2000 messages to the observer (exceeding the 1024 buffer), then asserts `run.exitCode() != 0`. TDD red phase confirmed deterministically: before the fix the test deadlocked (the buggy `offer` silently dropped both the overflowing alarms AND the `ALARM_FEED_END` sentinel that arrived after the queue filled, so the drain loop's `queue.take()` blocked forever); the background gradle run had to be killed with `TaskStop`. After the fix the same test exits in <1 second with the overflow exception propagating through picocli.
|
||||||
|
|
||||||
### Client.Java-034
|
### Client.Java-034
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -601,7 +605,7 @@ The library-side `MxEventStream` (Client.Java-002 resolution) and `DeployEventSt
|
|||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` |
|
| Location | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `BatchCommand.call()` reads one CLI invocation per stdin line and tokenises with:
|
**Description:** `BatchCommand.call()` reads one CLI invocation per stdin line and tokenises with:
|
||||||
|
|
||||||
@@ -622,6 +626,8 @@ The current `MxGatewayCliTests` test set (`batchCommandExecutesVersionAndEmitsEo
|
|||||||
|
|
||||||
**Recommendation:** Replace `line.trim().split("\\s+")` with a real shell-style tokeniser that honours single and double quotes and backslash escapes — `picocli.CommandLine.ArgumentParser` doesn't ship one, but Apache Commons Exec's `CommandLine.translateCommandline(String)`, JDK 21's `java.util.spi.ToolProvider` argument parsing, or a small hand-written state machine all work. Cross-check the .NET / Go / Rust / Python `batch` implementations in the same change so all five clients use the same tokenisation; document the contract in the protocol comment in `MxGatewayCli.java` and in `scripts/run-client-e2e-tests.ps1`. Add a CLI test that feeds `acknowledge-alarm --comment "with spaces"` through `batch` and asserts the `--comment` value reaches the gateway as `"with spaces"`.
|
**Recommendation:** Replace `line.trim().split("\\s+")` with a real shell-style tokeniser that honours single and double quotes and backslash escapes — `picocli.CommandLine.ArgumentParser` doesn't ship one, but Apache Commons Exec's `CommandLine.translateCommandline(String)`, JDK 21's `java.util.spi.ToolProvider` argument parsing, or a small hand-written state machine all work. Cross-check the .NET / Go / Rust / Python `batch` implementations in the same change so all five clients use the same tokenisation; document the contract in the protocol comment in `MxGatewayCli.java` and in `scripts/run-client-e2e-tests.ps1`. Add a CLI test that feeds `acknowledge-alarm --comment "with spaces"` through `batch` and asserts the `--comment` value reaches the gateway as `"with spaces"`.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Confirmed root cause: `BatchCommand.call()` at the per-line loop used `line.trim().split("\\s+")` which has no quote handling. Replaced with a new package-private `MxGatewayCli.tokenizeBatchLine(String)` static helper — a hand-rolled POSIX-style shell tokenizer (no new dependency added) that honours: (a) double-quoted runs `"..."` with `\\`, `\"`, and `\n` escapes inside; (b) single-quoted runs `'...'` taken literally with no escapes (POSIX rule); (c) backslash escapes for any single character outside quotes (so `needs\ verification` is one token); (d) whitespace runs outside quotes separate tokens; (e) explicit `IllegalArgumentException` on unterminated quote or trailing backslash so the batch loop surfaces it as a JSON error instead of emitting wrong args. The `BatchCommand` per-line tokenisation now calls `tokenizeBatchLine(line)` and treats an empty-array result as a blank line (skip). Behaviour for whitespace-only input is unchanged. The cross-client `batch` audit (.NET / Go / Rust / Python) is out of scope for this Java-focused finding and tracked separately. Regression tests in `MxGatewayCliTests`: (a) `batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces` — `--comment "needs verification"` round-trips intact; (b) `batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces` — single-quoted variant; (c) `batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes` — `needs\ verification` outside quotes; (d) `batchCommandPreservesEmptyQuotedArgument` — `""` parses to an empty-string argument; (e) `batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes` — `\"inner\"` survives the inner quotes. TDD red phase confirmed: all five tests failed against the original `split("\\s+")` implementation; after the fix all five pass.
|
||||||
|
|
||||||
### Client.Java-035
|
### Client.Java-035
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -629,7 +635,7 @@ The current `MxGatewayCliTests` test set (`batchCommandExecutesVersionAndEmitsEo
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` |
|
| Location | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** Commit `8a0c59d` added `MxGatewayClient.streamAlarms(StreamAlarmsRequest, StreamObserver<AlarmFeedMessage>)` and a new public `MxGatewayAlarmFeedSubscription` class. No library-side test exercises either: a grep for `streamAlarms` across `zb-mom-ww-mxgateway-client/src/test/...` returns zero matches. The CLI tests (`MxGatewayCliTests.streamAlarmsCommand*`) exercise the path end-to-end, but they route through a `FakeClient.streamAlarms` override that bypasses the production `subscription.wrap(observer)` glue and the `withStreamDeadline(rawAsyncStub()).streamAlarms(...)` call. A regression to either — forgetting `.wrap(observer)`, dropping the deadline interceptor, misnaming the request — would compile and pass the CLI tests but break against a real gateway.
|
**Description:** Commit `8a0c59d` added `MxGatewayClient.streamAlarms(StreamAlarmsRequest, StreamObserver<AlarmFeedMessage>)` and a new public `MxGatewayAlarmFeedSubscription` class. No library-side test exercises either: a grep for `streamAlarms` across `zb-mom-ww-mxgateway-client/src/test/...` returns zero matches. The CLI tests (`MxGatewayCliTests.streamAlarmsCommand*`) exercise the path end-to-end, but they route through a `FakeClient.streamAlarms` override that bypasses the production `subscription.wrap(observer)` glue and the `withStreamDeadline(rawAsyncStub()).streamAlarms(...)` call. A regression to either — forgetting `.wrap(observer)`, dropping the deadline interceptor, misnaming the request — would compile and pass the CLI tests but break against a real gateway.
|
||||||
|
|
||||||
@@ -637,6 +643,8 @@ This is the same coverage gap pattern as Client.Java-030 (no fixture test for `Q
|
|||||||
|
|
||||||
**Recommendation:** Add `streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages` to `MxGatewayClientSessionTests` (in-process gRPC via the existing `InProcessGateway` + `TestGatewayService` fixture): override `TestGatewayService.streamAlarms` to capture the inbound `StreamAlarmsRequest` and emit one `active_alarm` snapshot, one `snapshot_complete`, and one `transition`, then complete. Call `MxGatewayClient.streamAlarms`, drain the observer via a `CountDownLatch`, and assert (a) the server observed the `alarm_filter_prefix`, (b) all three messages arrived in order with the expected payload-case, and (c) `MxGatewayAlarmFeedSubscription.cancel()` aborts the call (latch via `ServerCallStreamObserver.setOnCancelHandler`, mirroring the Client.Java-015 cancellation regression). Optionally also cover the cancel-before-beforeStart race that `MxGatewayAlarmFeedSubscription.wrap` handles, mirroring `mxEventStreamCloseBeforeBeforeStartCancelsStream`.
|
**Recommendation:** Add `streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages` to `MxGatewayClientSessionTests` (in-process gRPC via the existing `InProcessGateway` + `TestGatewayService` fixture): override `TestGatewayService.streamAlarms` to capture the inbound `StreamAlarmsRequest` and emit one `active_alarm` snapshot, one `snapshot_complete`, and one `transition`, then complete. Call `MxGatewayClient.streamAlarms`, drain the observer via a `CountDownLatch`, and assert (a) the server observed the `alarm_filter_prefix`, (b) all three messages arrived in order with the expected payload-case, and (c) `MxGatewayAlarmFeedSubscription.cancel()` aborts the call (latch via `ServerCallStreamObserver.setOnCancelHandler`, mirroring the Client.Java-015 cancellation regression). Optionally also cover the cancel-before-beforeStart race that `MxGatewayAlarmFeedSubscription.wrap` handles, mirroring `mxEventStreamCloseBeforeBeforeStartCancelsStream`.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Confirmed the coverage gap: a grep across `zb-mom-ww-mxgateway-client/src/test/...` for `streamAlarms` returned zero matches; the CLI-only test routed through `FakeClient.streamAlarms` which bypassed both the production `subscription.wrap(observer)` and the `withStreamDeadline(rawAsyncStub()).streamAlarms(...)` gRPC call. Added `streamAlarmsForwardsRequestAndStreamsAlarmFeedMessages` to `MxGatewayClientSessionTests` in the same shape as `queryActiveAlarmsForwardsRequestAndStreamsSnapshots` (Client.Java-030 resolved this way). The test overrides `TestGatewayService.streamAlarms` to capture the inbound `StreamAlarmsRequest`, register a `serverCancelled` latch via `(ServerCallStreamObserver<AlarmFeedMessage>) responseObserver).setOnCancelHandler(...)`, then emit three messages: an `active_alarm` snapshot, a `snapshot_complete` sentinel, and a `transition`. It deliberately does NOT call `onCompleted()` so the call remains open for the cancellation assertion. The test then calls `MxGatewayClient.streamAlarms` against the in-process gateway, drains the wrapped observer via a `threeReceived` `CountDownLatch`, and asserts (a) the server observed `alarm_filter_prefix=Tank01`, (b) all three messages arrived in order with the expected payload-case (`ACTIVE_ALARM`, `SNAPSHOT_COMPLETE`, `TRANSITION`) and payload values (`Tank01.Level.HiHi`, transition kind `ACKNOWLEDGE`), and (c) `subscription.cancel()` causes the server's on-cancel handler to fire within 5 s (proves cancellation propagates through the production `subscription.wrap(observer)` glue, not just the CLI fake). TDD red phase: temporarily replaced the production `MxGatewayClient.streamAlarms` body with `withStreamDeadline(rawAsyncStub()).streamAlarms(request, observer);` (dropping the `subscription.wrap(observer)` indirection); the test failed at the `serverCancelled.await` assertion because cancellation was no longer wired to the underlying gRPC call. Restoring the production glue turned the build green.
|
||||||
|
|
||||||
### Client.Java-036
|
### Client.Java-036
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -644,7 +652,7 @@ This is the same coverage gap pattern as Client.Java-030 (no fixture test for `Q
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Location | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` |
|
| Location | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `MxGatewayAlarmFeedSubscription` is a structural near-copy of `MxGatewayEventSubscription` — same `AtomicReference<ClientCallStreamObserver<…>>` + `AtomicBoolean cancelled` field shape, the same `wrap(observer)` returning a `ClientResponseObserver` that stores `requestStream` in `beforeStart`, the same close-before-beforeStart race handling that Client.Java-014 originally fixed for `MxEventStream`, and the same `cancel()`+`close()` idempotency contract. The four subscription classes (`MxGatewayEventSubscription`, `MxGatewayActiveAlarmsSubscription`, `MxGatewayAlarmFeedSubscription`, `DeployEventSubscription`) are now ~60-line near-clones differing only in the request/response generic parameters and the `cancel` message string.
|
**Description:** `MxGatewayAlarmFeedSubscription` is a structural near-copy of `MxGatewayEventSubscription` — same `AtomicReference<ClientCallStreamObserver<…>>` + `AtomicBoolean cancelled` field shape, the same `wrap(observer)` returning a `ClientResponseObserver` that stores `requestStream` in `beforeStart`, the same close-before-beforeStart race handling that Client.Java-014 originally fixed for `MxEventStream`, and the same `cancel()`+`close()` idempotency contract. The four subscription classes (`MxGatewayEventSubscription`, `MxGatewayActiveAlarmsSubscription`, `MxGatewayAlarmFeedSubscription`, `DeployEventSubscription`) are now ~60-line near-clones differing only in the request/response generic parameters and the `cancel` message string.
|
||||||
|
|
||||||
@@ -652,4 +660,6 @@ This is the same maintenance-hazard pattern Client.Java-009 / Client.Java-016 id
|
|||||||
|
|
||||||
**Recommendation:** Extract a package-private abstract base, e.g. `MxGatewayStreamSubscription<TRequest>`, holding the `AtomicReference` / `AtomicBoolean` pair, the `cancel()` / `close()` implementation, and a `ClientResponseObserver` factory parameterised by the cancel-message string and the response observer. Have all four subscription classes extend it. Behaviour-only refactor — no public API change, existing tests cover the contract.
|
**Recommendation:** Extract a package-private abstract base, e.g. `MxGatewayStreamSubscription<TRequest>`, holding the `AtomicReference` / `AtomicBoolean` pair, the `cancel()` / `close()` implementation, and a `ClientResponseObserver` factory parameterised by the cancel-message string and the response observer. Have all four subscription classes extend it. Behaviour-only refactor — no public API change, existing tests cover the contract.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Extracted a package-private abstract base `MxGatewayStreamSubscription<TRequest, TResponse> implements AutoCloseable` (new file `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayStreamSubscription.java`). It holds the shared `AtomicReference<ClientCallStreamObserver<TRequest>>` and `AtomicBoolean cancelled` pair, the `wrap(StreamObserver<TResponse>)` factory that returns a `ClientResponseObserver` with the Client.Java-014 close-before-beforeStart fix baked in, the `cancel()` / `close()` implementation, and an immutable `cancelMessage` injected by the subclass constructor. The four prior 60-line near-clones (`MxGatewayEventSubscription`, `MxGatewayAlarmFeedSubscription`, `MxGatewayActiveAlarmsSubscription`, `DeployEventSubscription`) collapse to ~10-line subclasses that only declare their `<Request, Response>` type parameters and supply the cancel-message string to `super(...)`. Public API surface is preserved: each subclass remains a `public final class` with a public no-arg constructor (the constructor was implicit on the original classes; I made it explicit `public` on the subclasses so the existing CLI `FakeClient.streamAlarms` in a different package can still `new MxGatewayAlarmFeedSubscription()`). The `wrap(...)` method is `final` and package-private on the base — same accessibility the four subclasses had before — so production callers in `MxGatewayClient`/`GalaxyRepositoryClient` see no change. New test file `MxGatewayStreamSubscriptionContractTests` exercises the lifecycle/cancellation contract identically across all four subclasses (16 tests, four per scenario): (a) cancel-before-beforeStart eagerly cancels the stream once it attaches with the subclass-specific message, (b) cancel-after-beforeStart forwards directly to the stream, (c) `close()` delegates to `cancel()`, (d) the wrapped observer forwards `onNext`/`onError`/`onCompleted` verbatim, and a compile-time `typeBoundsCheck` helper that asserts each subclass still binds its `<Req, Resp>` parameters to the right proto types. TDD red phase confirmed: temporarily breaking one subclass's `super(...)` message to `"BROKEN MESSAGE"` made the contract test for that subclass fail with `expected: <client cancelled alarm feed> but was: <BROKEN MESSAGE>`; restoring the correct value turned all 16 contract tests green. Future fixes to the shared lifecycle now live in one place — the next Client.Java-014/021-style race fix cannot drift across the four classes.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-24 |
|
| Review date | 2026-05-24 |
|
||||||
| Commit reviewed | `42b0037` |
|
| Commit reviewed | `42b0037` |
|
||||||
| Status | Re-reviewed |
|
| Status | Re-reviewed |
|
||||||
| Open findings | 1 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ The Write parity test (IntegrationTests-012's resolution) added exactly this ass
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Location | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) |
|
| Location | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The new regression test for IntegrationTests-022 builds an "isolated" start directory under `Path.GetTempPath()` (e.g. `C:\Users\<user>\AppData\Local\Temp\<random>\nested` on Windows) and calls `ResolveRepositoryRoot(isolatedStart)`, asserting an `InvalidOperationException` is thrown. The walker walks every parent — `<random>`, `Temp`, `Local`, `AppData`, `<user>`, `Users`, `C:\` — and stops only when it either finds a repository root marker or runs out of parents. The test silently assumes none of those ancestor directories satisfies `IsRepositoryRoot` (a `src/` subdirectory next to `.git` / `*.sln` / `*.slnx`). The assumption is environment-dependent:
|
**Description:** The new regression test for IntegrationTests-022 builds an "isolated" start directory under `Path.GetTempPath()` (e.g. `C:\Users\<user>\AppData\Local\Temp\<random>\nested` on Windows) and calls `ResolveRepositoryRoot(isolatedStart)`, asserting an `InvalidOperationException` is thrown. The walker walks every parent — `<random>`, `Temp`, `Local`, `AppData`, `<user>`, `Users`, `C:\` — and stops only when it either finds a repository root marker or runs out of parents. The test silently assumes none of those ancestor directories satisfies `IsRepositoryRoot` (a `src/` subdirectory next to `.git` / `*.sln` / `*.slnx`). The assumption is environment-dependent:
|
||||||
|
|
||||||
@@ -504,3 +504,5 @@ The Write parity test (IntegrationTests-012's resolution) added exactly this ass
|
|||||||
The current dev box layout (`C:\Users\dohertj2\Desktop\mxaccessgw`) is safe because Temp is at `C:\Users\dohertj2\AppData\Local\Temp` and the walker exits at `C:\` without ever encountering `src/`. The fragility is invisible on this machine and only surfaces if the test ever runs in CI / on a contributor box with a less hermetic file-system layout.
|
The current dev box layout (`C:\Users\dohertj2\Desktop\mxaccessgw`) is safe because Temp is at `C:\Users\dohertj2\AppData\Local\Temp` and the walker exits at `C:\` without ever encountering `src/`. The fragility is invisible on this machine and only surfaces if the test ever runs in CI / on a contributor box with a less hermetic file-system layout.
|
||||||
|
|
||||||
**Recommendation:** Isolate the walker from any ambient ancestor by either (a) constructing an `isolatedRoot` directly under a drive root and pointing the walker at a chain entirely under it (e.g. create `<isolatedRoot>\level1\level2\level3` and start the walk at `level3`, then assert the throw — the walker stops at the drive root regardless of what is on it), (b) refactoring `ResolveRepositoryRoot` to accept an injectable `stopBoundary` parameter for tests and pass `isolatedRoot` as the boundary, or (c) replacing the `Assert.Throws` shape with an explicit upward-walk check that the test owns. Option (a) is the smallest change: prepend a sentinel — e.g. create a dummy `<isolatedRoot>\sentinel-no-markers` and assert nothing about Temp ancestors — and pass the test only when the walker reaches that sentinel without finding a marker. The current shape is acceptable on the documented dev box but should not be the sole regression coverage for IntegrationTests-022.
|
**Recommendation:** Isolate the walker from any ambient ancestor by either (a) constructing an `isolatedRoot` directly under a drive root and pointing the walker at a chain entirely under it (e.g. create `<isolatedRoot>\level1\level2\level3` and start the walk at `level3`, then assert the throw — the walker stops at the drive root regardless of what is on it), (b) refactoring `ResolveRepositoryRoot` to accept an injectable `stopBoundary` parameter for tests and pass `isolatedRoot` as the boundary, or (c) replacing the `Assert.Throws` shape with an explicit upward-walk check that the test owns. Option (a) is the smallest change: prepend a sentinel — e.g. create a dummy `<isolatedRoot>\sentinel-no-markers` and assert nothing about Temp ancestors — and pass the test only when the walker reaches that sentinel without finding a marker. The current shape is acceptable on the documented dev box but should not be the sole regression coverage for IntegrationTests-022.
|
||||||
|
|
||||||
|
**Resolution:** Resolved 2026-05-24 — Took option (b) (inject a stop-boundary) because option (a) does not actually solve the leak: a sentinel chain under `Path.GetTempPath()` still leaves the walker free to ascend past it into Temp / AppData / Users / C:\, so any ambient ancestor with `src/` + `.git`/`.sln`/`.slnx` still wins. Added an optional `stopBoundary` parameter to `IntegrationTestEnvironment.ResolveRepositoryRoot(string startDirectory, string? stopBoundary = null)`. When supplied, the walker checks the boundary for markers and then stops, refusing to ascend past it; production callers (the `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE` resolution path) continue to pass `null` so the walk to drive-root behavior is unchanged. Updated both existing tests (`ResolveRepositoryRoot_AcceptsGitWorktreeFile` and `ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) to pass their owned temp directory as the boundary, sealing the walker inside a chain the test fully controls. Added a new regression test `ResolveRepositoryRoot_StopBoundary_IsolatesWalkerFromAmbientAncestorMarkers` that deliberately constructs an outer marker-bearing ancestor (`outerRoot/src` + `outerRoot/.git`), an inner boundary, and an isolated start beneath the boundary; first asserts that without the boundary the walker leaks up to `outerRoot` (the precise IntegrationTests-025 failure mode), then asserts that *with* the boundary the same call throws — proving the boundary is the load-bearing isolation. TDD red/green confirmed: the new regression test fails against the pre-fix walker (`Assert.Throws() Failure: No exception was thrown`) and passes once the boundary handling is restored. Re-ran the full `IntegrationTestEnvironmentTests` slice with `TMP` / `TEMP` redirected under a deliberately constructed `<temp>\fake-repo-ancestor` directory carrying `src/` and a `.git` file — the original flake repro from the finding — and confirmed all 5 tests pass (the same redirection produced `Assert.Throws() Failure` on the pre-fix code). Build: 0 warnings / 0 errors.
|
||||||
|
|||||||
+15
-16
@@ -12,13 +12,13 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
|||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| [Client.Dotnet](Client.Dotnet/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 21 |
|
| [Client.Dotnet](Client.Dotnet/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 21 |
|
||||||
| [Client.Go](Client.Go/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 27 |
|
| [Client.Go](Client.Go/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 27 |
|
||||||
| [Client.Java](Client.Java/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 5 | 36 |
|
| [Client.Java](Client.Java/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 36 |
|
||||||
| [Client.Python](Client.Python/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 26 |
|
| [Client.Python](Client.Python/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 26 |
|
||||||
| [Client.Rust](Client.Rust/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 29 |
|
| [Client.Rust](Client.Rust/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 29 |
|
||||||
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 17 |
|
| [Contracts](Contracts/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 17 |
|
||||||
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 1 | 25 |
|
| [IntegrationTests](IntegrationTests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 25 |
|
||||||
| [Server](Server/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 50 |
|
| [Server](Server/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 50 |
|
||||||
| [Tests](Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 5 | 31 |
|
| [Tests](Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 31 |
|
||||||
| [Worker](Worker/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 25 |
|
| [Worker](Worker/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 25 |
|
||||||
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 30 |
|
| [Worker.Tests](Worker.Tests/findings.md) | Claude Code | 2026-05-24 | `42b0037` | Re-reviewed | 0 | 30 |
|
||||||
|
|
||||||
@@ -26,19 +26,7 @@ Each module's `findings.md` is the source of truth; this file is generated from
|
|||||||
|
|
||||||
Findings with status `Open` or `In Progress`, ordered by severity.
|
Findings with status `Open` or `In Progress`, ordered by severity.
|
||||||
|
|
||||||
| ID | Severity | Category | Location | Description |
|
_No pending findings._
|
||||||
|---|---|---|---|---|
|
|
||||||
| Client.Java-032 | High | Documentation & comments | `clients/java/README.md:182-183` | Commit `8738735` ("clients: document StreamAlarms + AcknowledgeAlarm in each README") added two new gradle invocations to the CLI Usage block: ``` gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-ke… |
|
|
||||||
| Client.Java-033 | Medium | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` | `StreamAlarmsCommand.call()` allocates a bounded `ArrayBlockingQueue<Object>(1024)` and the gRPC observer publishes each `AlarmFeedMessage` via `queue.offer(value)`: ``` BlockingQueue<Object> queue = new ArrayBlockingQueue<>(1024); … @Over… |
|
|
||||||
| Client.Java-034 | Medium | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` | `BatchCommand.call()` reads one CLI invocation per stdin line and tokenises with: ``` String[] args = line.trim().split("\\s+"); … int exitCode = cmd.execute(args); ``` `split("\\s+")` does no shell-quoting parsing — it just splits on whit… |
|
|
||||||
| Tests-027 | Medium | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` | The review brief explicitly flagged `MxAccessGatewayServiceTests.StreamEvents_WhenEventIsWritten_RecordsSendDuration` as a known flake that "passed solo on rerun". The root cause is the `MeterListener` subscribes by `instrument.Meter.Name… |
|
|
||||||
| Client.Java-035 | Low | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` | Commit `8a0c59d` added `MxGatewayClient.streamAlarms(StreamAlarmsRequest, StreamObserver<AlarmFeedMessage>)` and a new public `MxGatewayAlarmFeedSubscription` class. No library-side test exercises either: a grep for `streamAlarms` across `… |
|
|
||||||
| Client.Java-036 | Low | Code organization & conventions | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` | `MxGatewayAlarmFeedSubscription` is a structural near-copy of `MxGatewayEventSubscription` — same `AtomicReference<ClientCallStreamObserver<…>>` + `AtomicBoolean cancelled` field shape, the same `wrap(observer)` returning a `ClientResponse… |
|
|
||||||
| IntegrationTests-025 | Low | Correctness & logic bugs | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) | The new regression test for IntegrationTests-022 builds an "isolated" start directory under `Path.GetTempPath()` (e.g. `C:\Users\<user>\AppData\Local\Temp\<random>\nested` on Windows) and calls `ResolveRepositoryRoot(isolatedStart)`, asser… |
|
|
||||||
| Tests-028 | Low | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` | The new `KillWorkerAsync_KillsWorkerAndRemovesSession` (line 466) and `KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound` (line 486) pin the new kill-path entry, but they do not pin the `reason` argument propagating through the chai… |
|
|
||||||
| Tests-029 | Low | Error handling & resilience | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` | The new `DashboardSessionAdminServiceTests` covers the happy path and the viewer-denial path for both `CloseSessionAsync` and `KillWorkerAsync`, plus `CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError` for the close-side `SessionNo… |
|
|
||||||
| Tests-030 | Low | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` | The three new `DeleteAsync_*` fixtures cover unauthorised user, success path with audit, and store-refuses-with-friendly-error. They do not exercise two production behaviours: (1) `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` (line 1… |
|
|
||||||
| Tests-031 | Low | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` | `ExecuteAsync_WhenSnapshotServiceThrowsOnce_ReconnectsAfterDelay` records `startedAt = DateTimeOffset.UtcNow` *before* calling `publisher.StartAsync(...)`, then asserts `secondSubscribeAt - startedAt >= reconnectDelay - 10ms` (line 59). Th… |
|
|
||||||
|
|
||||||
## Closed findings
|
## Closed findings
|
||||||
|
|
||||||
@@ -49,6 +37,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Server-001 | Critical | Resolved | Security | `src/MxGateway.Server/GatewayApplication.cs:147-149`, `src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:55-58`, `src/MxGateway.Server/Dashboard/Components/Routes.razor:1-15` |
|
| Server-001 | Critical | Resolved | Security | `src/MxGateway.Server/GatewayApplication.cs:147-149`, `src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:55-58`, `src/MxGateway.Server/Dashboard/Components/Routes.razor:1-15` |
|
||||||
| Client.Go-001 | High | Resolved | Correctness & logic bugs | `clients/go/mxgateway/errors.go:88-93`, `clients/go/mxgateway/errors.go:117-128` |
|
| Client.Go-001 | High | Resolved | Correctness & logic bugs | `clients/go/mxgateway/errors.go:88-93`, `clients/go/mxgateway/errors.go:117-128` |
|
||||||
| Client.Java-013 | High | Resolved | Testing coverage | `clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java:212-304`, `clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java:1214-1244` |
|
| Client.Java-013 | High | Resolved | Testing coverage | `clients/java/mxgateway-cli/src/test/java/com/dohertylan/mxgateway/cli/MxGatewayCliTests.java:212-304`, `clients/java/mxgateway-cli/src/main/java/com/dohertylan/mxgateway/cli/MxGatewayCli.java:1214-1244` |
|
||||||
|
| Client.Java-032 | High | Resolved | Documentation & comments | `clients/java/README.md:182-183` |
|
||||||
| Client.Python-018 | High | Resolved | Code organization & conventions | `clients/python/pyproject.toml:11` |
|
| Client.Python-018 | High | Resolved | Code organization & conventions | `clients/python/pyproject.toml:11` |
|
||||||
| Client.Python-022 | High | Resolved | Documentation & comments | `clients/python/README.md:201-202`, `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:389-420` |
|
| Client.Python-022 | High | Resolved | Documentation & comments | `clients/python/README.md:201-202`, `clients/python/src/zb_mom_ww_mxgateway_cli/commands.py:389-420` |
|
||||||
| Client.Rust-001 | High | Resolved | mxaccessgw conventions | `clients/rust/src/options.rs:98,143` |
|
| Client.Rust-001 | High | Resolved | mxaccessgw conventions | `clients/rust/src/options.rs:98,143` |
|
||||||
@@ -86,6 +75,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Client.Java-021 | Medium | Resolved | Concurrency & thread safety | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java:96-135` |
|
| Client.Java-021 | Medium | Resolved | Concurrency & thread safety | `clients/java/mxgateway-client/src/main/java/com/dohertylan/mxgateway/client/DeployEventStream.java:96-135` |
|
||||||
| Client.Java-027 | Medium | Resolved | Documentation & comments | `clients/java/README.md:36,107-175,185,205,220`, `clients/java/JavaClientDesign.md:195-211` |
|
| Client.Java-027 | Medium | Resolved | Documentation & comments | `clients/java/README.md:36,107-175,185,205,220`, `clients/java/JavaClientDesign.md:195-211` |
|
||||||
| Client.Java-028 | Medium | Resolved | Documentation & comments | `clients/java/JavaClientDesign.md:23-27` |
|
| Client.Java-028 | Medium | Resolved | Documentation & comments | `clients/java/JavaClientDesign.md:23-27` |
|
||||||
|
| Client.Java-033 | Medium | Resolved | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:1078-1098` |
|
||||||
|
| Client.Java-034 | Medium | Resolved | Correctness & logic bugs | `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java:182-198` |
|
||||||
| Client.Python-003 | Medium | Resolved | Error handling & resilience | `clients/python/src/mxgateway/client.py:125-137,155-173` |
|
| Client.Python-003 | Medium | Resolved | Error handling & resilience | `clients/python/src/mxgateway/client.py:125-137,155-173` |
|
||||||
| Client.Python-005 | Medium | Resolved | Performance & resource management | `clients/python/src/mxgateway/galaxy.py:117-140` |
|
| Client.Python-005 | Medium | Resolved | Performance & resource management | `clients/python/src/mxgateway/galaxy.py:117-140` |
|
||||||
| Client.Python-009 | Medium | Resolved | Testing coverage | `clients/python/tests/` |
|
| Client.Python-009 | Medium | Resolved | Testing coverage | `clients/python/tests/` |
|
||||||
@@ -130,6 +121,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Tests-016 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs:29-41,115-124` |
|
| Tests-016 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs:29-41,115-124` |
|
||||||
| Tests-020 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs:275-347`, `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:803-829` |
|
| Tests-020 | Medium | Resolved | Testing coverage | `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs:275-347`, `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:803-829` |
|
||||||
| Tests-026 | Medium | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs`, `src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs:123-126` |
|
| Tests-026 | Medium | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs`, `src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs:123-126` |
|
||||||
|
| Tests-027 | Medium | Resolved | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` |
|
||||||
| Worker-004 | Medium | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:565-588` |
|
| Worker-004 | Medium | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:565-588` |
|
||||||
| Worker-005 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:205-258` (production alarm poll loop) |
|
| Worker-005 | Medium | Resolved | Error handling & resilience | `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:205-258` (production alarm poll loop) |
|
||||||
| Worker-006 | Medium | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:117-124`, `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:386-491` |
|
| Worker-006 | Medium | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:117-124`, `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:386-491` |
|
||||||
@@ -205,6 +197,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Client.Java-029 | Low | Resolved | Documentation & comments | `clients/java/README.md:208-209` |
|
| Client.Java-029 | Low | Resolved | Documentation & comments | `clients/java/README.md:208-209` |
|
||||||
| Client.Java-030 | Low | Resolved | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/` |
|
| Client.Java-030 | Low | Resolved | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/` |
|
||||||
| Client.Java-031 | Low | Resolved | mxaccessgw conventions | `clients/java/README.md:13,17,26` |
|
| Client.Java-031 | Low | Resolved | mxaccessgw conventions | `clients/java/README.md:13,17,26` |
|
||||||
|
| Client.Java-035 | Low | Resolved | Testing coverage | `clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientSessionTests.java` |
|
||||||
|
| Client.Java-036 | Low | Resolved | Code organization & conventions | `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayAlarmFeedSubscription.java`, `MxGatewayEventSubscription.java`, `MxGatewayActiveAlarmsSubscription.java`, `DeployEventSubscription.java` |
|
||||||
| Client.Python-001 | Low | Resolved | Documentation & comments | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` |
|
| Client.Python-001 | Low | Resolved | Documentation & comments | `clients/python/pyproject.toml:8,25`, `clients/python/src/mxgateway_cli/commands.py:25` |
|
||||||
| Client.Python-002 | Low | Resolved | Code organization & conventions | `clients/python/src/mxgateway/__init__.py:27` |
|
| Client.Python-002 | Low | Resolved | Code organization & conventions | `clients/python/src/mxgateway/__init__.py:27` |
|
||||||
| Client.Python-004 | Low | Resolved | Correctness & logic bugs | `clients/python/src/mxgateway_cli/commands.py:386,402-404` |
|
| Client.Python-004 | Low | Resolved | Correctness & logic bugs | `clients/python/src/mxgateway_cli/commands.py:386,402-404` |
|
||||||
@@ -268,6 +262,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| IntegrationTests-022 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironment.cs:103-138` (`ResolveRepositoryRoot` / `IsRepositoryRoot`) |
|
| IntegrationTests-022 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironment.cs:103-138` (`ResolveRepositoryRoot` / `IsRepositoryRoot`) |
|
||||||
| IntegrationTests-023 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs:14-29` |
|
| IntegrationTests-023 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs:14-29` |
|
||||||
| IntegrationTests-024 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs` (`NullDashboardEventBroadcaster` private class at end of file) |
|
| IntegrationTests-024 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs` (`NullDashboardEventBroadcaster` private class at end of file) |
|
||||||
|
| IntegrationTests-025 | Low | Resolved | Correctness & logic bugs | `src/ZB.MOM.WW.MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs:57-84` (`ResolveRepositoryRoot_NoMarkers_ThrowsInvalidOperationExceptionNamingStartAndMarkers`) |
|
||||||
| Server-007 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs:55-70` |
|
| Server-007 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs:55-70` |
|
||||||
| Server-008 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:111-134,160-189` |
|
| Server-008 | Low | Resolved | Performance & resource management | `src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs:111-134,160-189` |
|
||||||
| Server-009 | Low | Resolved | Error handling & resilience | `src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs:15-32` |
|
| Server-009 | Low | Resolved | Error handling & resilience | `src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs:15-32` |
|
||||||
@@ -318,6 +313,10 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
|
|||||||
| Tests-023 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs:334-374` |
|
| Tests-023 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs:334-374` |
|
||||||
| Tests-024 | Low | Resolved | Testing coverage | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:713-730,784-801,859-876`, `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs` |
|
| Tests-024 | Low | Resolved | Testing coverage | `src/MxGateway.Server/Grpc/MxAccessGatewayService.cs:713-730,784-801,859-876`, `src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceConstraintTests.cs` |
|
||||||
| Tests-025 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs:285-289`, `src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:417-421` |
|
| Tests-025 | Low | Resolved | Code organization & conventions | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs:285-289`, `src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs:417-421` |
|
||||||
|
| Tests-028 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` |
|
||||||
|
| Tests-029 | Low | Resolved | Error handling & resilience | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` |
|
||||||
|
| Tests-030 | Low | Resolved | Testing coverage | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` |
|
||||||
|
| Tests-031 | Low | Resolved | Concurrency & thread safety | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` |
|
||||||
| Worker-009 | Low | Resolved | Performance & resource management | `src/MxGateway.Worker/Ipc/WorkerFrameReader.cs:31,49`, `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:57-58` |
|
| Worker-009 | Low | Resolved | Performance & resource management | `src/MxGateway.Worker/Ipc/WorkerFrameReader.cs:31,49`, `src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs:57-58` |
|
||||||
| Worker-010 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Conversion/VariantConverter.cs:204-226` |
|
| Worker-010 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Conversion/VariantConverter.cs:204-226` |
|
||||||
| Worker-011 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeClient.cs:169-171` |
|
| Worker-011 | Low | Resolved | Correctness & logic bugs | `src/MxGateway.Worker/Ipc/WorkerPipeClient.cs:169-171` |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Review date | 2026-05-24 |
|
| Review date | 2026-05-24 |
|
||||||
| Commit reviewed | `42b0037` |
|
| Commit reviewed | `42b0037` |
|
||||||
| Status | Re-reviewed |
|
| Status | Re-reviewed |
|
||||||
| Open findings | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Checklist coverage
|
## Checklist coverage
|
||||||
|
|
||||||
@@ -488,12 +488,14 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
|||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` |
|
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs:199-240`, `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:8,73,246-251` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The review brief explicitly flagged `MxAccessGatewayServiceTests.StreamEvents_WhenEventIsWritten_RecordsSendDuration` as a known flake that "passed solo on rerun". The root cause is the `MeterListener` subscribes by `instrument.Meter.Name == GatewayMetrics.MeterName` (a *process-shared* constant `"MxGateway.Server"`), not by the specific `GatewayMetrics` instance constructed in the test. Tests-012 made the xUnit parallelism policy explicit (`parallelizeTestCollections: true`, `maxParallelThreads: -1`), and every other test that builds its own `GatewayMetrics()` and exercises `MxAccessGatewayService.StreamEvents` or `EventStreamService.StreamEventsAsync` (e.g. the new `StreamEventsAsync_*` family added by Tests-026 and Server-041, plus the pre-existing `StreamEventsAsync_YieldsEventsInWorkerOrder` etc.) routes through `GatewayMetrics.RecordEventStreamSend` → the same histogram name `mxgateway.events.stream_send.duration`. When two such tests run concurrently in the same xUnit process, the `MeterListener` in this test sees measurements from *both* meters and `families.Count` grows to >1, breaking `Assert.Equal([MxEventFamily.OnDataChange.ToString()], families)`. Solo reruns pass because no other producer is alive. This is exactly the cross-test mutable-state pattern Tests-012 set the guardrail comment against.
|
**Description:** The review brief explicitly flagged `MxAccessGatewayServiceTests.StreamEvents_WhenEventIsWritten_RecordsSendDuration` as a known flake that "passed solo on rerun". The root cause is the `MeterListener` subscribes by `instrument.Meter.Name == GatewayMetrics.MeterName` (a *process-shared* constant `"MxGateway.Server"`), not by the specific `GatewayMetrics` instance constructed in the test. Tests-012 made the xUnit parallelism policy explicit (`parallelizeTestCollections: true`, `maxParallelThreads: -1`), and every other test that builds its own `GatewayMetrics()` and exercises `MxAccessGatewayService.StreamEvents` or `EventStreamService.StreamEventsAsync` (e.g. the new `StreamEventsAsync_*` family added by Tests-026 and Server-041, plus the pre-existing `StreamEventsAsync_YieldsEventsInWorkerOrder` etc.) routes through `GatewayMetrics.RecordEventStreamSend` → the same histogram name `mxgateway.events.stream_send.duration`. When two such tests run concurrently in the same xUnit process, the `MeterListener` in this test sees measurements from *both* meters and `families.Count` grows to >1, breaking `Assert.Equal([MxEventFamily.OnDataChange.ToString()], families)`. Solo reruns pass because no other producer is alive. This is exactly the cross-test mutable-state pattern Tests-012 set the guardrail comment against.
|
||||||
|
|
||||||
**Recommendation:** Either (a) filter the `MeterListener` callback by the specific `Meter` instance — capture `metrics._meter` (or expose `GatewayMetrics.Meter`) and compare with `ReferenceEquals(instrument.Meter, expectedMeter)` instead of comparing `Meter.Name`; or (b) place this test in a single-threaded `[Collection("GatewayMetrics-Listener")]` so no other `RecordEventStreamSend` producer runs concurrently. Option (a) is preferred because it removes the cross-talk vector permanently and lets the test stay parallelisable.
|
**Recommendation:** Either (a) filter the `MeterListener` callback by the specific `Meter` instance — capture `metrics._meter` (or expose `GatewayMetrics.Meter`) and compare with `ReferenceEquals(instrument.Meter, expectedMeter)` instead of comparing `Meter.Name`; or (b) place this test in a single-threaded `[Collection("GatewayMetrics-Listener")]` so no other `RecordEventStreamSend` producer runs concurrently. Option (a) is preferred because it removes the cross-talk vector permanently and lets the test stay parallelisable.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Applied option (a). Added an `internal Meter Meter => _meter;` accessor on `GatewayMetrics` (visible to the Tests project via the existing `InternalsVisibleTo`) and changed both the `InstrumentPublished` filter and the `SetMeasurementEventCallback<double>` filter in `StreamEvents_WhenEventIsWritten_RecordsSendDuration` from `instrument.Meter.Name == GatewayMetrics.MeterName` to `ReferenceEquals(instrument.Meter, metrics.Meter)`. Added a companion regression `StreamEvents_RecordSendDurationListener_IgnoresMeasurementsFromOtherMetersWithSameName` that constructs a second `GatewayMetrics`, records an `OnWriteComplete` measurement on it before the test-under-test publishes, and asserts the listener captures only the test-under-test's `OnDataChange` family. Confirmed the regression catches the original `Meter.Name`-only filter (got `["OnWriteComplete", "OnDataChange"]` for `["OnDataChange"]`) by temporarily reverting the filter shape; restored ReferenceEquals after. Suite green 3/3 (512/512); the two Tests-027 tests pass 5/5 solo. The cross-talk vector is permanently closed without giving up parallelism.
|
||||||
|
|
||||||
### Tests-028
|
### Tests-028
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -501,12 +503,14 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` |
|
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs:466-496,802-807`, `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:216-253` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The new `KillWorkerAsync_KillsWorkerAndRemovesSession` (line 466) and `KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound` (line 486) pin the new kill-path entry, but they do not pin the `reason` argument propagating through the chain. `SessionManager.KillWorkerAsync(sessionId, reason, ct)` validates `reason` with `ArgumentException.ThrowIfNullOrWhiteSpace(reason)` (line 221), calls `session.KillWorker(reason)` (line 229), and logs `reason={Reason}` (line 251); but the `FakeWorkerClient.Kill(string reason)` discards the argument (line 803-807) and the assertion is only `Assert.Equal(1, workerClient.KillCount)`. A regression that (a) hard-coded an internal `"unspecified"` reason between `SessionManager` and `GatewaySession`, (b) swapped to a different overload that dropped the reason, or (c) deleted the `ThrowIfNullOrWhiteSpace` guard would all pass the current tests. The dashboard caller (`DashboardSessionAdminService.KillWorkerAsync`) passes a hard-coded `"dashboard-admin-kill"` reason and the only test that observes it (`KillWorkerAsync_AdminKillsWorker`) asserts `!string.IsNullOrWhiteSpace(LastKillReason)` rather than pinning the value — so the same-class drift is also untested.
|
**Description:** The new `KillWorkerAsync_KillsWorkerAndRemovesSession` (line 466) and `KillWorkerAsync_WhenSessionMissing_ThrowsSessionNotFound` (line 486) pin the new kill-path entry, but they do not pin the `reason` argument propagating through the chain. `SessionManager.KillWorkerAsync(sessionId, reason, ct)` validates `reason` with `ArgumentException.ThrowIfNullOrWhiteSpace(reason)` (line 221), calls `session.KillWorker(reason)` (line 229), and logs `reason={Reason}` (line 251); but the `FakeWorkerClient.Kill(string reason)` discards the argument (line 803-807) and the assertion is only `Assert.Equal(1, workerClient.KillCount)`. A regression that (a) hard-coded an internal `"unspecified"` reason between `SessionManager` and `GatewaySession`, (b) swapped to a different overload that dropped the reason, or (c) deleted the `ThrowIfNullOrWhiteSpace` guard would all pass the current tests. The dashboard caller (`DashboardSessionAdminService.KillWorkerAsync`) passes a hard-coded `"dashboard-admin-kill"` reason and the only test that observes it (`KillWorkerAsync_AdminKillsWorker`) asserts `!string.IsNullOrWhiteSpace(LastKillReason)` rather than pinning the value — so the same-class drift is also untested.
|
||||||
|
|
||||||
**Recommendation:** (1) Capture `LastKillReason` on `FakeWorkerClient.Kill` and assert `KillWorkerAsync_KillsWorkerAndRemovesSession` propagates the test-supplied `"test-kill"` string end-to-end. (2) Add `KillWorkerAsync_WithBlankReason_ThrowsArgumentException` (parameterised over `null`, `""`, `" "`) to pin the `ArgumentException.ThrowIfNullOrWhiteSpace` guard. (3) Tighten `DashboardSessionAdminServiceTests.KillWorkerAsync_AdminKillsWorker` to `Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason)` so a future reason-string change is a deliberate test update.
|
**Recommendation:** (1) Capture `LastKillReason` on `FakeWorkerClient.Kill` and assert `KillWorkerAsync_KillsWorkerAndRemovesSession` propagates the test-supplied `"test-kill"` string end-to-end. (2) Add `KillWorkerAsync_WithBlankReason_ThrowsArgumentException` (parameterised over `null`, `""`, `" "`) to pin the `ArgumentException.ThrowIfNullOrWhiteSpace` guard. (3) Tighten `DashboardSessionAdminServiceTests.KillWorkerAsync_AdminKillsWorker` to `Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason)` so a future reason-string change is a deliberate test update.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Added `LastKillReason` to `FakeWorkerClient` in `SessionManagerTests.cs` and set it inside `Kill(string reason)`. Tightened `KillWorkerAsync_KillsWorkerAndRemovesSession` to assert `workerClient.LastKillReason == "test-kill"`, pinning the end-to-end propagation from `SessionManager.KillWorkerAsync` → `session.KillWorker(reason)` → `IWorkerClient.Kill(reason)`. Added `KillWorkerAsync_WithBlankReason_ThrowsArgumentException` as a `[Theory]` over `""`, `" "`, `"\t"` plus a separate `KillWorkerAsync_WithNullReason_ThrowsArgumentNullException` fact (xUnit `InlineData` cannot carry `null` for a non-nullable string, and `ArgumentException.ThrowIfNullOrWhiteSpace` throws `ArgumentNullException` for `null`). Both new tests confirm `KillCount == 0` and the session remains registered, proving the guard fires before any lookup or worker call. Tightened `DashboardSessionAdminServiceTests.KillWorkerAsync_AdminKillsWorker` to `Assert.Equal("dashboard-admin-kill", sessionManager.LastKillReason)`. All affected tests pass; suite green.
|
||||||
|
|
||||||
### Tests-029
|
### Tests-029
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -514,12 +518,16 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` |
|
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAdminServiceTests.cs:61-106,139-222`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAdminService.cs:77-125` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The new `DashboardSessionAdminServiceTests` covers the happy path and the viewer-denial path for both `CloseSessionAsync` and `KillWorkerAsync`, plus `CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError` for the close-side `SessionNotFound` catch — but the kill-side error branches are not tested. The product code's `KillWorkerAsync` (lines 111-114) has the same `SessionNotFound` catch returning `"Session {id} was not found."` and (lines 115-124) a generic `SessionManagerException` catch returning `"Kill failed: {message}"`; neither is exercised. The fake's `KillWorkerAsync` (lines 200-209) only succeeds — there is no `KillThrowsNotFound` / `KillThrowsGeneric` configuration option matching the existing `CloseThrowsNotFound`. Symmetrically, `CloseSessionAsync` has the same `IsNullOrWhiteSpace(sessionId)` guard (line 37-40) but no blank-id test even though `KillWorkerAsync_BlankSessionId_ReturnsFailure` exists for the parallel kill guard — a guard-removal regression on close would slip through.
|
**Description:** The new `DashboardSessionAdminServiceTests` covers the happy path and the viewer-denial path for both `CloseSessionAsync` and `KillWorkerAsync`, plus `CloseSessionAsync_WhenSessionMissing_ReportsFriendlyError` for the close-side `SessionNotFound` catch — but the kill-side error branches are not tested. The product code's `KillWorkerAsync` (lines 111-114) has the same `SessionNotFound` catch returning `"Session {id} was not found."` and (lines 115-124) a generic `SessionManagerException` catch returning `"Kill failed: {message}"`; neither is exercised. The fake's `KillWorkerAsync` (lines 200-209) only succeeds — there is no `KillThrowsNotFound` / `KillThrowsGeneric` configuration option matching the existing `CloseThrowsNotFound`. Symmetrically, `CloseSessionAsync` has the same `IsNullOrWhiteSpace(sessionId)` guard (line 37-40) but no blank-id test even though `KillWorkerAsync_BlankSessionId_ReturnsFailure` exists for the parallel kill guard — a guard-removal regression on close would slip through.
|
||||||
|
|
||||||
**Recommendation:** Mirror the existing close-side fixtures onto the kill side: add `KillThrowsNotFound` / `KillThrowsGeneric` init-flags to the `FakeSessionManager`, then `KillWorkerAsync_WhenSessionMissing_ReportsFriendlyError`, `KillWorkerAsync_WhenSessionManagerThrows_ReportsKillFailedMessage`, and `CloseSessionAsync_BlankSessionId_ReturnsFailure`. These are mechanical copies of the existing patterns and bring close/kill coverage into symmetry.
|
**Recommendation:** Mirror the existing close-side fixtures onto the kill side: add `KillThrowsNotFound` / `KillThrowsGeneric` init-flags to the `FakeSessionManager`, then `KillWorkerAsync_WhenSessionMissing_ReportsFriendlyError`, `KillWorkerAsync_WhenSessionManagerThrows_ReportsKillFailedMessage`, and `CloseSessionAsync_BlankSessionId_ReturnsFailure`. These are mechanical copies of the existing patterns and bring close/kill coverage into symmetry.
|
||||||
|
|
||||||
|
**Re-triage note:** The Server batch already added `CloseSessionAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail` and `KillWorkerAsync_WhenManagerThrowsUnexpected_ReturnsFriendlyFail` (the Server-050 regressions visible at HEAD lines 125-162 of the test file), so the kill-side `SessionManagerException` general-catch branch and the close-side parallel are both covered there in a generic-exception shape. The only remaining asymmetry was the blank-session-id guard, per the prompt scope.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Added `CloseSessionAsync_BlankSessionId_ReturnsFailure` to `DashboardSessionAdminServiceTests`. The new test invokes `service.CloseSessionAsync(adminUser, " ", ct)` and asserts `Succeeded == false` and `sessionManager.CloseCount == 0`, pinning the `string.IsNullOrWhiteSpace(sessionId)` guard at `DashboardSessionAdminService.cs:52-55`. This brings close/kill blank-id coverage into symmetry with the existing `KillWorkerAsync_BlankSessionId_ReturnsFailure`. The `KillThrowsNotFound` / `KillThrowsGeneric` extensions from the original recommendation are not needed because the unexpected-throw branches are already covered by the Server-050 regressions noted above. All tests pass; suite green.
|
||||||
|
|
||||||
### Tests-030
|
### Tests-030
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -527,12 +535,14 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` |
|
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs:115-163`, `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:146-177` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** The three new `DeleteAsync_*` fixtures cover unauthorised user, success path with audit, and store-refuses-with-friendly-error. They do not exercise two production behaviours: (1) `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` (line 151-163) does not construct or inject a `FakeApiKeyAuditStore`, so it never observes that the product code still emits an audit entry with `EventType = "dashboard-delete-key"` and `Details = "not-found-or-active"` on the failure branch (`AppendAuditAsync` runs unconditionally at line 167-172). A regression that placed the `AppendAuditAsync` call inside the `if (deleted)` branch would silently drop the audit trail for refused deletes — a real audit-completeness gap. (2) There is no `DeleteAsync_BlankKeyId_ReturnsFailure` or `DeleteAsync_InvalidKeyId_ReturnsFailure` test, even though `ValidateKeyId(keyId)` (line 156-160) guards on the same conditions as Create/Revoke/Rotate. The `Revoke`/`Rotate` paths have equivalent fixtures (the file's earlier tests cover them); only Delete is missing them.
|
**Description:** The three new `DeleteAsync_*` fixtures cover unauthorised user, success path with audit, and store-refuses-with-friendly-error. They do not exercise two production behaviours: (1) `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` (line 151-163) does not construct or inject a `FakeApiKeyAuditStore`, so it never observes that the product code still emits an audit entry with `EventType = "dashboard-delete-key"` and `Details = "not-found-or-active"` on the failure branch (`AppendAuditAsync` runs unconditionally at line 167-172). A regression that placed the `AppendAuditAsync` call inside the `if (deleted)` branch would silently drop the audit trail for refused deletes — a real audit-completeness gap. (2) There is no `DeleteAsync_BlankKeyId_ReturnsFailure` or `DeleteAsync_InvalidKeyId_ReturnsFailure` test, even though `ValidateKeyId(keyId)` (line 156-160) guards on the same conditions as Create/Revoke/Rotate. The `Revoke`/`Rotate` paths have equivalent fixtures (the file's earlier tests cover them); only Delete is missing them.
|
||||||
|
|
||||||
**Recommendation:** (1) Add a `FakeApiKeyAuditStore` to `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` and assert it contains exactly one entry with `EventType == "dashboard-delete-key"` and `Details == "not-found-or-active"`. (2) Add `DeleteAsync_BlankKeyId_ReturnsFailure` (parameterised over `null`, `""`, `" "`) and `DeleteAsync_InvalidKeyId_ReturnsFailure` (a keyId with characters the `ValidateKeyId` rules reject) to pin the validation branch end-to-end.
|
**Recommendation:** (1) Add a `FakeApiKeyAuditStore` to `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` and assert it contains exactly one entry with `EventType == "dashboard-delete-key"` and `Details == "not-found-or-active"`. (2) Add `DeleteAsync_BlankKeyId_ReturnsFailure` (parameterised over `null`, `""`, `" "`) and `DeleteAsync_InvalidKeyId_ReturnsFailure` (a keyId with characters the `ValidateKeyId` rules reject) to pin the validation branch end-to-end.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Renamed `DeleteAsync_WhenStoreRefuses_ReportsFriendlyError` to `DeleteAsync_WhenStoreRefuses_ReportsFriendlyErrorAndAudits` and extended it to inject a `FakeApiKeyAuditStore`; the test now asserts the single audit entry has `EventType == "dashboard-delete-key"`, `KeyId == "operator01"`, and `Details == "not-found-or-active"`. This pins the unconditional-audit invariant at `DashboardApiKeyManagementService.cs:167-172` — a regression moving the `AppendAuditAsync` call inside `if (deleted)` would now fail the test. Added `DeleteAsync_BlankKeyId_ReturnsFailure` as a `[Theory]` over `""`, `" "`, `"\t"` that asserts `Succeeded == false`, `adminStore.DeleteCount == 0`, AND `auditStore.Entries` is empty — pinning that the `ValidateKeyId` guard at line 156-160 fires before any store or audit work. All tests pass; suite green.
|
||||||
|
|
||||||
### Tests-031
|
### Tests-031
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
@@ -540,8 +550,10 @@ The cancellation tests for `WorkerClient` in `WorkerClientTests` *do* exercise t
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` |
|
| Location | `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotPublisherTests.cs:22-61` |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
|
|
||||||
**Description:** `ExecuteAsync_WhenSnapshotServiceThrowsOnce_ReconnectsAfterDelay` records `startedAt = DateTimeOffset.UtcNow` *before* calling `publisher.StartAsync(...)`, then asserts `secondSubscribeAt - startedAt >= reconnectDelay - 10ms` (line 59). The measured gap is *not* the reconnect delay in isolation — it is `(StartAsync scheduling) + (first WatchSnapshotsAsync setup + Task.Yield) + (throw) + reconnect delay + (second WatchSnapshotsAsync setup)`. On a slow/contended CI agent the first three terms easily dominate (favouring the assertion); but on a fast machine, Windows `Task.Delay(50ms)` rounds up to the next ~15.6 ms tick boundary and may return at ~46-50 ms relative to schedule, while the first three terms can be sub-millisecond — so the gap measurement can land within 1-2 ms of the lower bound, and the 10 ms slack may not absorb a single missed quantum. This is a latent flake of the same flavour as Tests-006 (heartbeat timing) but on a wall-clock dependency the test cannot inject around because `DashboardSnapshotPublisher` uses `Task.Delay(_reconnectDelay)` directly. Tests-006 / Tests-017 moved heartbeat tests onto `ManualTimeProvider`; this test cannot do that without a product change to use a `TimeProvider`-aware delay.
|
**Description:** `ExecuteAsync_WhenSnapshotServiceThrowsOnce_ReconnectsAfterDelay` records `startedAt = DateTimeOffset.UtcNow` *before* calling `publisher.StartAsync(...)`, then asserts `secondSubscribeAt - startedAt >= reconnectDelay - 10ms` (line 59). The measured gap is *not* the reconnect delay in isolation — it is `(StartAsync scheduling) + (first WatchSnapshotsAsync setup + Task.Yield) + (throw) + reconnect delay + (second WatchSnapshotsAsync setup)`. On a slow/contended CI agent the first three terms easily dominate (favouring the assertion); but on a fast machine, Windows `Task.Delay(50ms)` rounds up to the next ~15.6 ms tick boundary and may return at ~46-50 ms relative to schedule, while the first three terms can be sub-millisecond — so the gap measurement can land within 1-2 ms of the lower bound, and the 10 ms slack may not absorb a single missed quantum. This is a latent flake of the same flavour as Tests-006 (heartbeat timing) but on a wall-clock dependency the test cannot inject around because `DashboardSnapshotPublisher` uses `Task.Delay(_reconnectDelay)` directly. Tests-006 / Tests-017 moved heartbeat tests onto `ManualTimeProvider`; this test cannot do that without a product change to use a `TimeProvider`-aware delay.
|
||||||
|
|
||||||
**Recommendation:** (a) The cheap fix: have `ThrowOnceThenYieldSnapshotService` record `_firstThrowAt = DateTimeOffset.UtcNow` immediately before the `throw`, and change the assertion to `secondSubscribeAt - firstThrowAt >= reconnectDelay - 10ms` — the gap then measures only the reconnect delay, eliminating the variable scheduling baseline. (b) The deeper fix: extend `DashboardSnapshotPublisher` to accept an `ITimeProvider`-style delay seam (or a virtual `DelayAsync` hook) so a `ManualTimeProvider` could advance time deterministically. (a) is preferred for now; (b) belongs as a follow-up if more reconnect-loop tests are added.
|
**Recommendation:** (a) The cheap fix: have `ThrowOnceThenYieldSnapshotService` record `_firstThrowAt = DateTimeOffset.UtcNow` immediately before the `throw`, and change the assertion to `secondSubscribeAt - firstThrowAt >= reconnectDelay - 10ms` — the gap then measures only the reconnect delay, eliminating the variable scheduling baseline. (b) The deeper fix: extend `DashboardSnapshotPublisher` to accept an `ITimeProvider`-style delay seam (or a virtual `DelayAsync` hook) so a `ManualTimeProvider` could advance time deterministically. (a) is preferred for now; (b) belongs as a follow-up if more reconnect-loop tests are added.
|
||||||
|
|
||||||
|
**Resolution:** 2026-05-24 — Applied option (a). Added `FirstThrowAt` to `ThrowOnceThenYieldSnapshotService` and set it via `FirstThrowAt = DateTimeOffset.UtcNow;` immediately before the first-call `throw`. Removed the pre-`StartAsync` `startedAt` baseline; the assertion now reads `gap = secondSubscribeAt - firstThrowAt` (both timestamps captured inside the fake), and the 10 ms slack absorbs the Windows `Task.Delay` quantum without the variable `StartAsync` / scheduling overhead in the baseline. This is the same flake-isolation pattern Tests-006 / Tests-017 used (measuring only the production delay, not test-side setup). Suite green; the test passes deterministically across repeated runs.
|
||||||
|
|||||||
+187
-38
@@ -67,9 +67,17 @@ list.
|
|||||||
|
|
||||||
## What this means
|
## What this means
|
||||||
|
|
||||||
The architecture comment on
|
> **Historical note (current as built).** This discovery record predates the
|
||||||
`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmClientConsumer.cs` (PR A.5) is
|
> as-built alarm path. The `AlarmClientConsumer.cs` file referenced below was
|
||||||
**wrong against this deployed assembly**:
|
> retired; the production consumer is
|
||||||
|
> `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (driven by the
|
||||||
|
> `wwAlarmConsumerClass` COM surface — see [Option A](#option-a--captured-2026-05-01)
|
||||||
|
> below). The current public RPC surface and broker architecture are summarized
|
||||||
|
> in [Current alarm path (as built)](#current-alarm-path-as-built) at the end of
|
||||||
|
> this document; the sections in between are kept as a discovery record.
|
||||||
|
|
||||||
|
The architecture comment on the (now-retired) `AlarmClientConsumer.cs` (PR A.5)
|
||||||
|
was **wrong against this deployed assembly**:
|
||||||
|
|
||||||
> "The AVEVA alarm-manager surface (`IAlarmMgrDataProvider`) exposes
|
> "The AVEVA alarm-manager surface (`IAlarmMgrDataProvider`) exposes
|
||||||
> the events we need as plain .NET events — no Windows message pump
|
> the events we need as plain .NET events — no Windows message pump
|
||||||
@@ -601,8 +609,14 @@ returned to normal but is unacknowledged — i.e., visible in the
|
|||||||
"current alarms" list because operator hasn't acked it yet) and
|
"current alarms" list because operator hasn't acked it yet) and
|
||||||
`UNACK_ALM` (the alarm is currently active and unacknowledged).
|
`UNACK_ALM` (the alarm is currently active and unacknowledged).
|
||||||
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
|
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
|
||||||
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
|
appear when an ack is performed.
|
||||||
is the method to call.
|
|
||||||
|
> **Forward reference / superseded:** an earlier draft named
|
||||||
|
> `wwAlarmConsumerClass.AlarmAckByGUID` as the ack method. That call turned out
|
||||||
|
> to be **`E_NOTIMPL`** on this AVEVA build (see
|
||||||
|
> [`AlarmAckByGUID` is not implemented](#4-alarmackbyguid-is-not-implemented)
|
||||||
|
> below). The as-built ack path is the v1 6-arg `AlarmAckByName` on a dedicated
|
||||||
|
> ack-only consumer instance. Do not wire acks through `AlarmAckByGUID`.
|
||||||
|
|
||||||
### `GetStatistics` AV — unrelated quirk
|
### `GetStatistics` AV — unrelated quirk
|
||||||
|
|
||||||
@@ -638,20 +652,25 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
|||||||
payload; diff against the previous snapshot (keyed by
|
payload; diff against the previous snapshot (keyed by
|
||||||
`GUID`); emit `MX_EVENT_FAMILY_ON_ALARM_TRANSITION`
|
`GUID`); emit `MX_EVENT_FAMILY_ON_ALARM_TRANSITION`
|
||||||
events for added/changed/removed records.
|
events for added/changed/removed records.
|
||||||
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
|
- Client-driven acknowledgements. (This draft named `AlarmAckByGUID` and a
|
||||||
fullName)` for client-driven acknowledgements (matches
|
`AlarmAckCommand` payload; as built the ack proto is
|
||||||
PR A.5's `AlarmAckCommand` payload).
|
`AcknowledgeAlarmCommand` / `AcknowledgeAlarmByNameCommand`, the consumer
|
||||||
|
interface method is `AcknowledgeByGuid` / `AcknowledgeByName`, and the GUID
|
||||||
|
path is `E_NOTIMPL` so only the by-name path runs — see
|
||||||
|
[`AlarmAckByGUID` is not implemented](#4-alarmackbyguid-is-not-implemented).)
|
||||||
- Lifecycle teardown: `DeregisterConsumer` +
|
- Lifecycle teardown: `DeregisterConsumer` +
|
||||||
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
|
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
|
||||||
3. **Conversion layer:** map XML record fields to
|
3. **Conversion layer:** map XML record fields to the alarm proto:
|
||||||
`MxAlarmConditionRecord` proto:
|
- `GUID` and `PROVIDER_NAME!GROUP.TAGNAME` → `alarm_full_reference` (there is
|
||||||
- `GUID` → `condition_id` (canonicalize the no-dashes hex
|
no `condition_id` field; the public RPC and worker carry the reference as
|
||||||
to a UUID string).
|
`alarm_full_reference`, either a canonical GUID or `Provider!Group.Tag`).
|
||||||
- `STATE` enum → `inAlarm` + `acked` booleans
|
- `STATE` → `AlarmConditionState` on `ActiveAlarmSnapshot.current_state`
|
||||||
(`UNACK_ALM` → in_alarm=true, acked=false;
|
(this draft used `inAlarm` + `acked` booleans, which the proto does not
|
||||||
`UNACK_RTN` → in_alarm=false, acked=false;
|
have). As built, the snapshot state collapses to three values:
|
||||||
`ACK_ALM` → in_alarm=true, acked=true;
|
`UNACK_ALM` → `Active`; `ACK_ALM` → `ActiveAcked`; `UNACK_RTN` and
|
||||||
`ACK_RTN` → in_alarm=false, acked=true).
|
`ACK_RTN` both → `Inactive` (a returned-to-normal alarm is no longer
|
||||||
|
"active"). For the live `transition` feed the `STATE` instead drives an
|
||||||
|
`AlarmTransitionKind` (`Raise` / `Acknowledge` / `Clear`).
|
||||||
- `DATE + TIME + GMTOFFSET + DSTADJUST` → reassemble UTC
|
- `DATE + TIME + GMTOFFSET + DSTADJUST` → reassemble UTC
|
||||||
timestamp; matches the worker's existing `Timestamp`
|
timestamp; matches the worker's existing `Timestamp`
|
||||||
wire format.
|
wire format.
|
||||||
@@ -663,10 +682,14 @@ alarm-consumer surface unblocks A.2 fully. Outline:
|
|||||||
`aaAlarmManagedClient`, also true here). The existing
|
`aaAlarmManagedClient`, also true here). The existing
|
||||||
`AlarmClientConsumer` skips Initialize entirely; the new
|
`AlarmClientConsumer` skips Initialize entirely; the new
|
||||||
`WnWrapAlarmConsumer` includes it from day one.
|
`WnWrapAlarmConsumer` includes it from day one.
|
||||||
5. **Test reuse:** PR A.5's snapshot/ack contract tests can
|
5. **Test reuse:** the snapshot/ack contract tests stayed — they don't touch
|
||||||
stay — they don't touch the underlying COM API. Add a new
|
the underlying COM API. As built, the alarm tests live under
|
||||||
integration test against the wnwrap surface (live-AVEVA-only,
|
`src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/` (`AlarmDispatcherTests`,
|
||||||
Skip-gated like the probe).
|
`AlarmRecordTransitionMapperTests`, `AlarmCommandHandlerTests`,
|
||||||
|
`AlarmCommandExecutorTests`, `WnWrapAlarmConsumerXmlTests`), with the
|
||||||
|
live-AVEVA-only round-trip in
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs`
|
||||||
|
(Skip-gated like the probe).
|
||||||
|
|
||||||
### Settled API-ordering and surface knowledge
|
### Settled API-ordering and surface knowledge
|
||||||
|
|
||||||
@@ -752,26 +775,47 @@ AVEVA fixes the v2 method later.
|
|||||||
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
|
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
|
||||||
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
|
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
|
||||||
build. The reference→GUID lookup that we initially planned to wire
|
build. The reference→GUID lookup that we initially planned to wire
|
||||||
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
|
through `AlarmAckByGUID` is therefore not viable on wnwrap; only the
|
||||||
must go through `AlarmAckByName`.
|
by-name path actually succeeds.
|
||||||
|
|
||||||
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
|
**Routing as built (and the GUID hazard).** The gateway-side router is
|
||||||
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
|
`GatewayAlarmMonitor.BuildAcknowledgeCommand` (there is no
|
||||||
in the codebase for the forward-compat shape, but the gateway-side
|
`WorkerAlarmRpcDispatcher` type). Routing is **conditional on the reference
|
||||||
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
|
shape**, not unconditional:
|
||||||
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
|
|
||||||
`Provider!Group.Tag` reference.
|
|
||||||
|
|
||||||
### 5. STA / threading — production fix needed
|
- A reference that `Guid.TryParse` accepts is built into
|
||||||
|
`MxCommandKind.AcknowledgeAlarm` / `AcknowledgeAlarmCommand` — the **GUID
|
||||||
|
path**, which the worker dispatches to `AlarmAckByGUID`.
|
||||||
|
- A `Provider!Group.Tag` reference (parsed by
|
||||||
|
`GatewayAlarmMonitor.TryParseAlarmReference`) is built into
|
||||||
|
`MxCommandKind.AcknowledgeAlarmByName` / `AcknowledgeAlarmByNameCommand` — the
|
||||||
|
by-name path, which is the only one that succeeds on this build.
|
||||||
|
- Anything else fails with an `alarm_full_reference` parse error before any
|
||||||
|
worker call.
|
||||||
|
|
||||||
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
|
The GUID arm is **still dispatched unguarded**: the proto
|
||||||
internal `Timer` fires on threadpool threads and would block forever
|
`AcknowledgeAlarmCommand` and the worker's
|
||||||
on cross-apartment marshaling unless the host STA pumps Win32
|
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain in the
|
||||||
messages. The smoke test sidesteps this by setting
|
codebase for forward compatibility, and `BuildAcknowledgeCommand` routes a
|
||||||
`pollIntervalMilliseconds=0` (Timer disabled) and driving `PollOnce`
|
GUID-shaped reference straight to them. On the deployed wnwrap build that path
|
||||||
manually from the test's STA. Production hosting will route polls
|
hits the `E_NOTIMPL` `AlarmAckByGUID` and surfaces a `COMException` rather than
|
||||||
through the worker's `StaRuntime` in a follow-up — the consumer's
|
acknowledging. **Practical guidance:** acknowledge with the
|
||||||
`PollOnce` is `public` and idempotent so the wire-up is mechanical.
|
`Provider!Group.Tag` reference (the same form the transition feed emits in
|
||||||
|
`alarm_full_reference`), not a raw GUID, until the GUID arm is either guarded or
|
||||||
|
AVEVA implements `AlarmAckByGUID`.
|
||||||
|
|
||||||
|
### 5. STA / threading
|
||||||
|
|
||||||
|
The wnwrap COM is `ThreadingModel=Apartment`, so every consumer call
|
||||||
|
(`Subscribe`, `PollOnce`, the `AcknowledgeBy*` methods) must run on the STA that
|
||||||
|
created the COM instance. As built, `WnWrapAlarmConsumer` owns **no internal
|
||||||
|
timer and takes no `pollIntervalMilliseconds` parameter** — an earlier draft
|
||||||
|
described a self-driven `Timer` that would have blocked on cross-apartment
|
||||||
|
marshaling, but that design was dropped. Instead `PollOnce()` is a `public`,
|
||||||
|
idempotent method the host drives on the worker's STA (via
|
||||||
|
`StaRuntime.InvokeAsync(() => consumer.PollOnce())`); the poll cadence lives in
|
||||||
|
the host, not the consumer. Each `PollOnce` reads `GetXmlCurrentAlarms2`, diffs
|
||||||
|
against the previous snapshot, and emits transition events.
|
||||||
|
|
||||||
### Capture summary
|
### Capture summary
|
||||||
|
|
||||||
@@ -790,3 +834,108 @@ Post-ack transition: kind=Clear …
|
|||||||
|
|
||||||
10s cadence held throughout; full proto fields populated correctly;
|
10s cadence held throughout; full proto fields populated correctly;
|
||||||
ack registered server-side without errors.
|
ack registered server-side without errors.
|
||||||
|
|
||||||
|
## Current alarm path (as built)
|
||||||
|
|
||||||
|
The sections above are a discovery record. This section summarizes the path that
|
||||||
|
actually ships, grounded in the current code. For the proto shapes see
|
||||||
|
[Contracts](./Contracts.md#alarm-rpcs-and-messages); for the server handlers see
|
||||||
|
[gRPC](./Grpc.md); for configuration see
|
||||||
|
[Gateway Configuration](./GatewayConfiguration.md#alarm-options).
|
||||||
|
|
||||||
|
### Public RPCs and configuration
|
||||||
|
|
||||||
|
Alarms are exposed through three **session-less** RPCs on `MxAccessGateway`:
|
||||||
|
`AcknowledgeAlarm`, `StreamAlarms`, and `QueryActiveAlarms`. No client opens a
|
||||||
|
worker session to use them. They are gated by `MxGateway:Alarms:*`:
|
||||||
|
|
||||||
|
- `MxGateway:Alarms:Enabled` (default `false`) turns the whole subsystem on.
|
||||||
|
- `MxGateway:Alarms:SubscriptionExpression` is the canonical
|
||||||
|
`\\<machine>\Galaxy!<area>` subscription; when empty, the monitor falls back
|
||||||
|
to `\\<MachineName>\Galaxy!<DefaultArea>` from `MxGateway:Alarms:DefaultArea`.
|
||||||
|
Enabled with both empty faults the monitor with a configuration diagnostic.
|
||||||
|
- `MxGateway:Alarms:ReconcileIntervalSeconds` (default 30, floored at 5) sets the
|
||||||
|
reconcile cadence below.
|
||||||
|
|
||||||
|
### The always-on `GatewayAlarmMonitor` broker
|
||||||
|
|
||||||
|
`GatewayAlarmMonitor` (`src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs`)
|
||||||
|
is registered by `AddGatewayAlarms` as a singleton, as the `IGatewayAlarmService`,
|
||||||
|
and as a hosted `BackgroundService`. When `Enabled`, it:
|
||||||
|
|
||||||
|
1. Opens **one** gateway-managed worker session dedicated to alarms (client name
|
||||||
|
`gateway-alarm-monitor`, backend `Galaxy`), after a brief startup grace so
|
||||||
|
worker launching and orphan cleanup settle.
|
||||||
|
2. Subscribes that session to the resolved subscription expression and feeds an
|
||||||
|
in-process active-alarm cache (`Dictionary<reference, ActiveAlarmSnapshot>`)
|
||||||
|
from the session's transition events.
|
||||||
|
3. Fans the feed out to **any number** of `StreamAlarms` subscribers — clients
|
||||||
|
never open their own session. The session is transparently re-opened with a
|
||||||
|
5-second backoff if the worker faults.
|
||||||
|
|
||||||
|
### `AlarmFeedMessage` stream protocol
|
||||||
|
|
||||||
|
`StreamAsync` (behind `StreamAlarms`) emits, in order:
|
||||||
|
|
||||||
|
1. one `AlarmFeedMessage { active_alarm }` per currently-cached alarm matching
|
||||||
|
the optional `alarm_filter_prefix`,
|
||||||
|
2. a single `AlarmFeedMessage { snapshot_complete = true }` sentinel,
|
||||||
|
3. then one `AlarmFeedMessage { transition }` per live change.
|
||||||
|
|
||||||
|
The subscriber is registered under the monitor lock **before** the snapshot is
|
||||||
|
taken, so no transition can slip between the snapshot and the live tail.
|
||||||
|
`QueryActiveAlarms` reuses the same cache but emits only the `active_alarm`
|
||||||
|
snapshots and completes — no sentinel, no transitions.
|
||||||
|
|
||||||
|
### Reconcile loop
|
||||||
|
|
||||||
|
A `PeriodicTimer` runs `ReconcileAsync` every
|
||||||
|
`max(5, ReconcileIntervalSeconds)` seconds. It pulls the worker's authoritative
|
||||||
|
active-alarm snapshot and replaces the cache, broadcasting a synthetic `Clear`
|
||||||
|
transition for any cached alarm the snapshot no longer contains and a synthetic
|
||||||
|
`Raise` for any alarm the snapshot adds. This catches transitions the live
|
||||||
|
poll-and-diff feed missed (e.g. across a transport blip). A failed reconcile
|
||||||
|
pass logs at Debug and keeps the current cache.
|
||||||
|
|
||||||
|
### Subscriber backpressure
|
||||||
|
|
||||||
|
Each subscriber gets a bounded channel of **2048** messages
|
||||||
|
(`SubscriberQueueCapacity`). When `Broadcast` cannot write to a subscriber (its
|
||||||
|
channel is full), that subscriber is **completed with an error and dropped** —
|
||||||
|
the error message tells the client to reconnect to re-snapshot. Backpressure
|
||||||
|
from one slow consumer never blocks the broker or other subscribers.
|
||||||
|
|
||||||
|
### Snapshot state collapse
|
||||||
|
|
||||||
|
`ActiveAlarmSnapshot.current_state` carries only three `AlarmConditionState`
|
||||||
|
values, so the four AVEVA `STATE`s collapse: `UNACK_ALM` → `Active`,
|
||||||
|
`ACK_ALM` → `ActiveAcked`, and both `UNACK_RTN` and `ACK_RTN` → `Inactive`
|
||||||
|
(`AlarmDispatcher`). A returned-to-normal alarm is reported as `Inactive` in a
|
||||||
|
snapshot even though it is still listed because it is unacknowledged. The live
|
||||||
|
`transition` feed instead reports `AlarmTransitionKind` (`Raise` / `Acknowledge`
|
||||||
|
/ `Clear`).
|
||||||
|
|
||||||
|
### `alarm_full_reference` parse contract
|
||||||
|
|
||||||
|
`AcknowledgeAlarm` accepts either form in `alarm_full_reference`
|
||||||
|
(`GatewayAlarmMonitor.BuildAcknowledgeCommand`):
|
||||||
|
|
||||||
|
- a canonical GUID (`Guid.TryParse`) → GUID ack path
|
||||||
|
(`AcknowledgeAlarmCommand`), which on the deployed wnwrap build hits the
|
||||||
|
`E_NOTIMPL` `AlarmAckByGUID` — see
|
||||||
|
[`AlarmAckByGUID` is not implemented](#4-alarmackbyguid-is-not-implemented);
|
||||||
|
- a `Provider!Group.Tag` reference (`TryParseAlarmReference`: first `!` splits
|
||||||
|
provider from `Group.Tag`, the first `.` after the `!` splits group from tag)
|
||||||
|
→ by-name ack path (`AcknowledgeAlarmByNameCommand`), the path that works;
|
||||||
|
- anything else → a parse error before any worker call.
|
||||||
|
|
||||||
|
The transition feed emits the `Provider!Group.Tag` form in
|
||||||
|
`alarm_full_reference`, so echoing that value back into `AcknowledgeAlarm` takes
|
||||||
|
the working by-name path.
|
||||||
|
|
||||||
|
### Reserved / unused
|
||||||
|
|
||||||
|
`AlarmTransitionKind.RETRIGGER` is defined in the proto but is **not currently
|
||||||
|
produced** — the transition mapper emits only `Raise` / `Acknowledge` / `Clear`.
|
||||||
|
It is reserved for a future "re-raise from a previously cleared condition"
|
||||||
|
distinction.
|
||||||
|
|||||||
+68
-50
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
The gateway authentication subsystem verifies inbound API key credentials against a SQLite-backed key store, hashes secrets with a configurable pepper, and records administrative and verification events to an audit trail.
|
The gateway authentication subsystem verifies inbound API key credentials against a SQLite-backed key store, hashes secrets with a configurable pepper, and records administrative and verification events to an audit trail.
|
||||||
|
|
||||||
|
The peppered-HMAC API-key pipeline — token format, parsing, secret generation and hashing, constant-time comparison, the SQLite schema, the stores, the verifier, and the migrator — lives in the shared `ZB.MOM.WW.Auth.ApiKeys` package (with abstractions in `ZB.MOM.WW.Auth.Abstractions`), of which this gateway is the donor. The gateway references the package and binds the library's `ApiKeyOptions` from its own `MxGateway:Authentication` section through `AddSqliteAuthStore`, then layers the gateway-specific pieces on top: constraint enforcement, the gRPC authorization interceptor, the admin CLI, the dashboard API Keys page, and canonical audit forwarding. Types whose code is shown below for reference are owned by the shared package unless noted; the gateway does not re-implement them.
|
||||||
|
|
||||||
## Token Format
|
## Token Format
|
||||||
|
|
||||||
API keys travel in the HTTP `Authorization` header as a bearer token shaped `mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the `<keyId>` segment is the public identifier used for lookup, and `<secret>` is the high-entropy portion that the gateway verifies against a stored hash.
|
API keys travel in the HTTP `Authorization` header as a bearer token shaped `mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the `<keyId>` segment is the public identifier used for lookup, and `<secret>` is the high-entropy portion that the gateway verifies against a stored hash.
|
||||||
|
|
||||||
`ApiKeyParser` enforces the format and rejects malformed tokens before any database round-trip:
|
The shared library's `ApiKeyParser` enforces the format and rejects malformed tokens before any database round-trip:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
|
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
|
||||||
@@ -50,7 +52,7 @@ public static string Generate()
|
|||||||
|
|
||||||
### Peppered hashing
|
### Peppered hashing
|
||||||
|
|
||||||
`ApiKeySecretHasher` (registered behind `IApiKeySecretHasher`) hashes secrets with `HMACSHA256` keyed by a server-side pepper. The pepper lives outside the database and is resolved by `IConfiguration` lookup against the configured `PepperSecretName`:
|
The shared library's `ApiKeySecretHasher` (behind `IApiKeySecretHasher`) hashes secrets with `HMACSHA256` keyed by a server-side pepper. The pepper lives outside the database and is resolved through an `IApiKeyPepperProvider` — the gateway wires the configuration-backed provider so the pepper comes from `IConfiguration` lookup against `MxGateway:ApiKeyPepper` (`PepperSecretName`):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public byte[] HashSecret(string secret)
|
public byte[] HashSecret(string secret)
|
||||||
@@ -69,37 +71,29 @@ The pepper is intentionally not stored alongside the hash: an attacker who exfil
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`ApiKeyVerifier` (`IApiKeyVerifier`) implements the verification flow:
|
The shared library's `IApiKeyVerifier.VerifyAsync(authorizationHeader, cancellationToken)` owns the whole verification flow — the gateway interceptor hands it the raw `authorization` header value and never parses the token itself:
|
||||||
|
|
||||||
1. Parse the `Authorization` header into a `ParsedApiKey`.
|
1. Parse the `Authorization` header into the key id and secret.
|
||||||
2. Look up the `ApiKeyRecord` by `KeyId` through `IApiKeyStore.FindByKeyIdAsync`.
|
2. Look up the record by key id.
|
||||||
3. Reject revoked records (`RevokedUtc is not null`).
|
3. Reject revoked records.
|
||||||
4. Hash the presented secret with the configured pepper.
|
4. Hash the presented secret with the configured pepper.
|
||||||
5. Compare hashes with `CryptographicOperations.FixedTimeEquals` to avoid timing oracles.
|
5. Compare hashes with `CryptographicOperations.FixedTimeEquals` to avoid timing oracles.
|
||||||
6. Record a `LastUsedUtc` timestamp via `MarkKeyUsedAsync` and return an `ApiKeyIdentity`.
|
6. Stamp `last_used_utc` and return an identity.
|
||||||
|
|
||||||
|
`VerifyAsync` returns an `ApiKeyVerification` value with a `Succeeded` flag and a nullable `Identity`. On failure the result is discriminated so the caller can tell parse errors, missing pepper, missing or revoked keys, and secret mismatch apart for audit detail — without leaking which check failed to the client. The gateway interceptor treats any non-success uniformly as `Unauthenticated` (see [Authorization](./Authorization.md)):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
|
ApiKeyVerification verification = await apiKeyVerifier
|
||||||
{
|
.VerifyAsync(authorizationHeader ?? string.Empty, context.CancellationToken)
|
||||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
if (!verification.Succeeded || verification.Identity is null)
|
||||||
KeyId: storedKey.KeyId,
|
{
|
||||||
KeyPrefix: storedKey.KeyPrefix,
|
throw new RpcException(new Status(StatusCode.Unauthenticated, "Missing or invalid API key."));
|
||||||
DisplayName: storedKey.DisplayName,
|
}
|
||||||
Scopes: storedKey.Scopes,
|
|
||||||
Constraints: storedKey.Constraints));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`ApiKeyVerificationResult` carries either an `ApiKeyIdentity` or a discriminated `ApiKeyVerificationFailure` value. The failure enum distinguishes parse errors, missing pepper, missing or revoked keys, and secret mismatch so the calling middleware can emit precise audit detail without leaking which check failed to the client.
|
The shared verifier returns `ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity`, which carries the persisted constraints as an opaque JSON string. The gateway's `GatewayApiKeyIdentityMapper.ToGatewayIdentity` projects it onto the gateway-local `ApiKeyIdentity` record, which exposes only non-secret fields (`KeyId`, `KeyPrefix`, `DisplayName`, `Scopes`) plus the deserialized `Constraints`, and is the type downstream authorization code consumes.
|
||||||
|
|
||||||
`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`,
|
|
||||||
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
|
|
||||||
authorization code consumes.
|
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
@@ -107,7 +101,7 @@ The gateway keeps API key state in a dedicated SQLite database. SQLite is suffic
|
|||||||
|
|
||||||
### Connection factory
|
### Connection factory
|
||||||
|
|
||||||
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
|
The shared library's `AuthSqliteConnectionFactory` (registered by `AddZbApiKeyAuth`) reads the bound `ApiKeyOptions.SqlitePath` — which the gateway populates from `MxGateway:Authentication:SqlitePath` — ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
SqliteConnectionStringBuilder builder = new()
|
SqliteConnectionStringBuilder builder = new()
|
||||||
@@ -119,21 +113,22 @@ SqliteConnectionStringBuilder builder = new()
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path.
|
Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and the canonical audit writer appends to the same file, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path.
|
||||||
|
|
||||||
### Schema
|
### Schema
|
||||||
|
|
||||||
`SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved:
|
The shared library's `SqliteAuthSchema` declares the API-key table names and the current schema version as constants. Four tables live in the database file:
|
||||||
|
|
||||||
- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob,
|
- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob,
|
||||||
`display_name`, serialized `scopes`, optional serialized `constraints`, and
|
`display_name`, serialized `scopes`, optional serialized `constraints`, and
|
||||||
the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps.
|
the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps.
|
||||||
- `api_key_audit` is an append-only log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns.
|
- `api_key_audit` is the shared library's append-only audit log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns. The gateway overrides the library audit store (see [Audit trail](#audit-trail)), so this table is **left in place but unused** at runtime — nothing writes to it.
|
||||||
|
- `audit_event` is the gateway-owned canonical audit table written by `SqliteCanonicalAuditStore`. It lives in the same SQLite file (reusing the library's `AuthSqliteConnectionFactory`) and is where every gateway audit event actually lands. See [Audit trail](#audit-trail).
|
||||||
- `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`.
|
- `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`.
|
||||||
|
|
||||||
### Read paths
|
### Read paths
|
||||||
|
|
||||||
`SqliteApiKeyStore` (`IApiKeyStore`) handles the two reads needed at request time: `FindByKeyIdAsync` returns any record (so revoked keys can be reported distinctly) and `FindActiveByKeyIdAsync` filters to non-revoked rows. `MarkKeyUsedAsync` updates `last_used_utc` only for non-revoked rows so a freshly revoked key cannot have its timestamp refreshed by a racing verification.
|
The shared library's `SqliteApiKeyStore` (`IApiKeyStore`) handles the two reads needed at request time: `FindByKeyIdAsync` returns any record (so revoked keys can be reported distinctly) and `FindActiveByKeyIdAsync` filters to non-revoked rows. `MarkKeyUsedAsync` updates `last_used_utc` only for non-revoked rows so a freshly revoked key cannot have its timestamp refreshed by a racing verification.
|
||||||
|
|
||||||
`ApiKeyRecord` is the in-memory projection. `ApiKeyRecordReader.Read` is shared by every read path so column ordering is defined in one place:
|
`ApiKeyRecord` is the in-memory projection. `ApiKeyRecordReader.Read` is shared by every read path so column ordering is defined in one place:
|
||||||
|
|
||||||
@@ -155,17 +150,21 @@ public static ApiKeyRecord Read(SqliteDataReader reader)
|
|||||||
|
|
||||||
### Write paths
|
### Write paths
|
||||||
|
|
||||||
`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable, and `DeleteAsync` permanently removes a row but only when `revoked_utc IS NOT NULL` — active keys are untouched (returns false) so the revoke event lands in the audit log before the row disappears.
|
The shared library's `SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable, and `DeleteAsync` permanently removes a row but only when `revoked_utc IS NOT NULL` — active keys are untouched (returns false) so the revoke event lands in the audit log before the row disappears.
|
||||||
|
|
||||||
Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) actions only for keys whose status is `Active`; revoked keys instead show a Delete action that calls `DeleteAsync`, so an operator can permanently remove a revoked row without ever risking un-revocation as a side effect of a rotation.
|
Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) actions only for keys whose status is `Active`; revoked keys instead show a Delete action that calls `DeleteAsync`, so an operator can permanently remove a revoked row without ever risking un-revocation as a side effect of a rotation.
|
||||||
|
|
||||||
### Audit trail
|
### Audit trail
|
||||||
|
|
||||||
`SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`.
|
All gateway audit flows through a single canonical `AuditEvent` written to the gateway-owned `audit_event` table, not the shared library's `api_key_audit` table. The gateway adopts `ZB.MOM.WW.Audit` and **overrides** the library's `IApiKeyAuditStore` registration with `CanonicalForwardingApiKeyAuditStore`. That adapter receives each library-emitted `ApiKeyAuditEntry` — including the library-internal admin-command verbs (`create-key`, `revoke-key`, `rotate-key`, `init-db`) the gateway cannot edit — canonicalizes it onto an `AuditEvent`, and forwards it through `IAuditWriter` (`CanonicalAuditWriter`), which persists to `audit_event` via `SqliteCanonicalAuditStore`.
|
||||||
|
|
||||||
|
Because the adapter is registered after `AddZbApiKeyAuth`, it is the `IApiKeyAuditStore` that the admin commands resolve and that the dashboard "recent audit" view reads through `IApiKeyAuditStore.ListRecentAsync`. The library's own `SqliteApiKeyAuditStore` and its `api_key_audit` table are therefore unused at runtime — the override is the only writer. Audit rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; non-key-scoped events such as `init-db` carry no key id.
|
||||||
|
|
||||||
|
This canonical-forwarding wiring lives under `src/ZB.MOM.WW.MxGateway.Server/Security/Audit/`; the audit store override and writer are gateway types, while the entry shape and admin verbs originate in the shared library.
|
||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
Schema bring-up is centralised behind `IAuthStoreMigrator`. `SqliteAuthStoreMigrator` executes the migration inside a single transaction so a partial failure leaves the database untouched, refuses to start when the on-disk schema version is newer than the binary supports, and idempotently creates the v1 schema:
|
Schema bring-up for the API-key tables is owned by the shared library's `SqliteAuthStoreMigrator`, wired by `AddZbApiKeyAuth` along with its migration hosted service. It executes the migration inside a single transaction so a partial failure leaves the database untouched, refuses to start when the on-disk schema version is newer than the binary supports, and idempotently creates the schema:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
||||||
@@ -179,13 +178,11 @@ await ApplyVersionOneAsync(connection, transaction, cancellationToken).Configure
|
|||||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
```
|
```
|
||||||
|
|
||||||
`AuthStoreMigrationHostedService` runs the migrator at startup, but only when API-key authentication is enabled and `RunMigrationsOnStartup` is true. Operators who manage schema out-of-band can disable the hosted run and use the admin CLI's `init-db` command instead.
|
The library's migration hosted service runs the migrator at startup. Operators who manage schema out-of-band can use the admin CLI's `init-db` command instead.
|
||||||
|
|
||||||
`AuthStoreMigrationException` is a sealed `InvalidOperationException` so it can be caught precisely without swallowing unrelated failures.
|
|
||||||
|
|
||||||
## Admin CLI
|
## Admin CLI
|
||||||
|
|
||||||
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult` carrying an error). `ApiKeyAdminCliRunner` then executes the command, runs the migrator first, calls the relevant store method, appends an audit row, and writes either text or JSON output via `ApiKeyAdminOutput`. The returned `ApiKeyAdminListedKey` projection deliberately omits the `secret_hash` so listing a database does not surface hash material.
|
`ApiKeyAdminCommandLineParser.Parse` (a gateway type) recognises a leading `apikey` argument and dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult` carrying an error). The parser validates requested `--scopes` against `GatewayScopes.All` (see [Authorization](./Authorization.md#scope-catalog)) so a non-canonical scope string cannot be persisted on a key. `ApiKeyAdminCliRunner` then drives the shared library's `ApiKeyAdminCommands` — which the gateway registers over the already-wired stores, pepper provider, and migrator — to execute the command, and writes either text or JSON output via `ApiKeyAdminOutput`. The returned `ApiKeyAdminListedKey` projection deliberately omits the `secret_hash` so listing a database does not surface hash material.
|
||||||
|
|
||||||
The supported subcommands match `ApiKeyAdminCommandKind` exactly:
|
The supported subcommands match `ApiKeyAdminCommandKind` exactly:
|
||||||
|
|
||||||
@@ -201,7 +198,7 @@ Examples:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mxgateway apikey init-db
|
mxgateway apikey init-db
|
||||||
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
|
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes invoke:read,invoke:write
|
||||||
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
|
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
|
||||||
mxgateway apikey list-keys --json
|
mxgateway apikey list-keys --json
|
||||||
mxgateway apikey revoke-key --key-id ops.alice
|
mxgateway apikey revoke-key --key-id ops.alice
|
||||||
@@ -226,7 +223,7 @@ confirmation dialog and emits its own audit event
|
|||||||
|
|
||||||
## Scope Serialization
|
## Scope Serialization
|
||||||
|
|
||||||
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
|
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. The shared library's `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public static string Serialize(IReadOnlySet<string> scopes)
|
public static string Serialize(IReadOnlySet<string> scopes)
|
||||||
@@ -249,29 +246,50 @@ public static IReadOnlySet<string> Deserialize(string value)
|
|||||||
|
|
||||||
`Deserialize` tolerates an empty column by returning an empty set so older rows or hand-edited records do not crash the verifier.
|
`Deserialize` tolerates an empty column by returning an empty set so older rows or hand-edited records do not crash the verifier.
|
||||||
|
|
||||||
|
## Dashboard Cookie and Hub Token
|
||||||
|
|
||||||
|
The API-key model above guards the gRPC surface. Interactive dashboard requests use a separate LDAP-backed cookie scheme (see [Gateway Dashboard Design](./GatewayDashboardDesign.md)). Two timeouts and a few configuration knobs govern that cookie:
|
||||||
|
|
||||||
|
- **Cookie idle timeout — 8 hours.** `DashboardServiceCollectionExtensions` applies the shared `ZbCookieDefaults.Apply` hardened cookie defaults (HttpOnly, `SameSite=Strict`, secure policy, sliding expiration) but overrides the library's 30-minute default with an 8-hour idle timeout, so an active operator is not signed out mid-shift. The expiration is sliding, so each authenticated request resets the window.
|
||||||
|
- **Hub bearer token — 30 minutes.** SignalR hub connections cannot always carry the HttpOnly cookie (the client SignalR JS may resolve the cookie scope to loopback), so the dashboard mints a short-lived data-protected bearer at `/hubs/token` via `HubTokenService`. The token lifetime is 30 minutes; the hubs accept either it or the cookie.
|
||||||
|
- **`MxGateway:Dashboard:CookieName`** overrides the cookie name (default `MxGatewayDashboard`, from `DashboardAuthenticationDefaults.CookieName`). Two gateway instances on the same host but different ports share a cookie scope — host+path, not port — so giving each a distinct name keeps their dashboard sessions from clobbering each other. Changing it signs out existing sessions on next deploy.
|
||||||
|
- **`MxGateway:Dashboard:RequireHttpsCookie`** (default `true`) restricts the cookie to HTTPS via `CookieSecurePolicy.Always`. Set it to `false` for plain-HTTP dev so the cookie uses `SameAsRequest`; leaving it `true` while serving the dashboard over plain HTTP from a non-localhost host breaks login, because browsers drop Secure cookies set over HTTP.
|
||||||
|
|
||||||
|
The dashboard issues claims through the shared `ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes` (e.g. `ZbClaimTypes.Username` = `zb:username`, `ZbClaimTypes.Name` = `ClaimTypes.Name` so `Identity.Name` resolves, `ZbClaimTypes.Role` = `ClaimTypes.Role` so `IsInRole`/`[Authorize(Roles=...)]` work). Cookie hardening defaults come from `ZbCookieDefaults`. Both live in the shared Auth packages, not the gateway.
|
||||||
|
|
||||||
## Registration
|
## Registration
|
||||||
|
|
||||||
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore` wires every service in this subsystem as a singleton and registers the migration hosted service:
|
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore` is the gateway entry point. It does not register the parser, hasher, verifier, stores, or migrator directly — those come from the shared package. Instead it delegates to the package's `AddZbApiKeyAuth` and then layers the gateway-specific audit and CLI services:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
public static IServiceCollection AddSqliteAuthStore(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
|
// Register the shared API-key provider: binds ApiKeyOptions from MxGateway:Authentication,
|
||||||
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
|
// wires up the SQLite stores, the configuration-backed pepper provider, the verifier, the
|
||||||
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
|
// migrator and the migration hosted service.
|
||||||
|
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
|
||||||
|
|
||||||
|
// Gateway-owned canonical audit (ZB.MOM.WW.Audit) in the same SQLite file.
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
new SqliteCanonicalAuditStore(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
|
||||||
|
services.AddSingleton<IAuditWriter>(sp => new CanonicalAuditWriter(/* ... */));
|
||||||
|
|
||||||
|
// Override the library's IApiKeyAuditStore so every audit lands in audit_event.
|
||||||
|
services.AddSingleton<IApiKeyAuditStore, CanonicalForwardingApiKeyAuditStore>();
|
||||||
|
|
||||||
|
// The shared admin command set, driven by the gateway CLI and dashboard.
|
||||||
|
services.AddSingleton(sp => new ApiKeyAdminCommands(/* ... */));
|
||||||
services.AddSingleton<ApiKeyAdminCliRunner>();
|
services.AddSingleton<ApiKeyAdminCliRunner>();
|
||||||
services.AddSingleton<AuthSqliteConnectionFactory>();
|
|
||||||
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
|
||||||
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
|
||||||
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
|
|
||||||
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
|
|
||||||
services.AddHostedService<AuthStoreMigrationHostedService>();
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Singletons are safe because each operation opens its own short-lived `SqliteConnection` through the factory; there is no shared mutable state inside the services.
|
The gateway pins its own API-key contract — token prefix `mxgw` and the pepper key `MxGateway:ApiKeyPepper` — by layering those as fallback defaults under the supplied configuration before calling `AddZbApiKeyAuth`, because `ApiKeyOptions` is an init-only record that must be bound with those values present rather than mutated afterward. Explicit configuration still wins. `AddZbApiKeyAuth` binds `ApiKeyOptions` from the `MxGateway:Authentication` section and registers the connection factory, stores, pepper provider, verifier, migrator, and migration hosted service.
|
||||||
|
|
||||||
|
The audit-store override is registered *after* `AddZbApiKeyAuth` so it replaces the library's `TryAddSingleton` registration. The shared admin command set is not auto-registered by `AddZbApiKeyAuth`, so the gateway registers `ApiKeyAdminCommands` itself over the wired stores; the CLI and dashboard drive it. Library services are singletons and safe because each operation opens its own short-lived `SqliteConnection` through the factory.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -58,32 +58,34 @@ if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
|||||||
}
|
}
|
||||||
|
|
||||||
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
||||||
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
ApiKeyVerification verification = await apiKeyVerifier
|
||||||
.VerifyAsync(authorizationHeader, context.CancellationToken)
|
.VerifyAsync(authorizationHeader ?? string.Empty, context.CancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
if (!verification.Succeeded || verification.Identity is null)
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(
|
throw new RpcException(new Status(
|
||||||
StatusCode.Unauthenticated,
|
StatusCode.Unauthenticated,
|
||||||
"Missing or invalid API key."));
|
"Missing or invalid API key."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
||||||
|
|
||||||
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
||||||
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
|
if (!identity.Scopes.Contains(requiredScope))
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(
|
throw new RpcException(new Status(
|
||||||
StatusCode.PermissionDenied,
|
StatusCode.PermissionDenied,
|
||||||
$"API key is missing required scope '{requiredScope}'."));
|
$"API key is missing required scope '{requiredScope}'."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return verificationResult.Identity;
|
return identity;
|
||||||
```
|
```
|
||||||
|
|
||||||
The flow is:
|
The flow is:
|
||||||
|
|
||||||
1. If `GatewayOptions.Authentication.Mode` is `AuthenticationMode.Disabled`, the helper returns `null` immediately. No identity is pushed onto the accessor and the continuation runs without scope enforcement. This matches the `AuthenticationMode` enum, which only defines `ApiKey` and `Disabled`.
|
1. If `GatewayOptions.Authentication.Mode` is `AuthenticationMode.Disabled`, the helper returns `null` immediately. No identity is pushed onto the accessor and the continuation runs without scope enforcement. This matches the `AuthenticationMode` enum, which only defines `ApiKey` and `Disabled`.
|
||||||
2. Otherwise, the `authorization` request header is read directly off `ServerCallContext.RequestHeaders` and handed to `IApiKeyVerifier.VerifyAsync`. A failed verification or a missing identity throws `RpcException` with `StatusCode.Unauthenticated`.
|
2. Otherwise, the `authorization` request header is read directly off `ServerCallContext.RequestHeaders` and handed to the shared `IApiKeyVerifier.VerifyAsync`, which returns an `ApiKeyVerification`. A failed verification or a missing identity throws `RpcException` with `StatusCode.Unauthenticated`. The shared library's identity is then projected onto the gateway-local `ApiKeyIdentity` by `GatewayApiKeyIdentityMapper.ToGatewayIdentity` before scope checks run.
|
||||||
3. `GatewayGrpcScopeResolver.ResolveRequiredScope(request)` produces the scope string. If the identity's `Scopes` set does not contain it, the helper throws `RpcException` with `StatusCode.PermissionDenied` and embeds the missing scope name in `Status.Detail` so callers can diagnose the failure.
|
3. `GatewayGrpcScopeResolver.ResolveRequiredScope(request)` produces the scope string. If the identity's `Scopes` set does not contain it, the helper throws `RpcException` with `StatusCode.PermissionDenied` and embeds the missing scope name in `Status.Detail` so callers can diagnose the failure.
|
||||||
4. On success, the verified `ApiKeyIdentity` is returned and pushed onto `IGatewayRequestIdentityAccessor` for the lifetime of the call.
|
4. On success, the verified `ApiKeyIdentity` is returned and pushed onto `IGatewayRequestIdentityAccessor` for the lifetime of the call.
|
||||||
|
|
||||||
@@ -107,7 +109,8 @@ public string ResolveRequiredScope(object request)
|
|||||||
TestConnectionRequest or
|
TestConnectionRequest or
|
||||||
GetLastDeployTimeRequest or
|
GetLastDeployTimeRequest or
|
||||||
DiscoverHierarchyRequest or
|
DiscoverHierarchyRequest or
|
||||||
WatchDeployEventsRequest => GatewayScopes.MetadataRead,
|
WatchDeployEventsRequest or
|
||||||
|
BrowseChildrenRequest => GatewayScopes.MetadataRead,
|
||||||
_ => GatewayScopes.Admin
|
_ => GatewayScopes.Admin
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -194,7 +197,7 @@ the gateway fails closed.
|
|||||||
Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read
|
Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read
|
||||||
commands preserve input order and return a failed `SubscribeResult` for each
|
commands preserve input order and return a failed `SubscribeResult` for each
|
||||||
denied item while still forwarding allowed items to the worker. Every denial
|
denied item while still forwarding allowed items to the worker. Every denial
|
||||||
adds an `api_key_audit` entry with the key id, command kind, target, and
|
records a canonical audit event with the key id, command kind, target, and
|
||||||
blocking constraint; secured values and raw credentials are never logged.
|
blocking constraint; secured values and raw credentials are never logged.
|
||||||
|
|
||||||
## Scope Catalog
|
## Scope Catalog
|
||||||
@@ -209,10 +212,10 @@ blocking constraint; secured values and raw credentials are never logged.
|
|||||||
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, `ReadBulk`, and any kind not otherwise mapped) |
|
||||||
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
| `InvokeWrite` | `invoke:write` | `AcknowledgeAlarmRequest`, `MxCommandKind.Write`, `MxCommandKind.Write2`, `MxCommandKind.WriteBulk`, `MxCommandKind.Write2Bulk` |
|
||||||
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.WriteSecuredBulk`, `MxCommandKind.WriteSecured2Bulk`, `MxCommandKind.AuthenticateUser` |
|
||||||
| `MetadataRead` | `metadata:read` | `MxCommandKind.ArchestraUserToId`, `MxCommandKind.GetSessionState`, `MxCommandKind.GetWorkerInfo`, `GalaxyRepository.TestConnection`, `GalaxyRepository.GetLastDeployTime`, `GalaxyRepository.DiscoverHierarchy`, `GalaxyRepository.WatchDeployEvents` |
|
| `MetadataRead` | `metadata:read` | `MxCommandKind.ArchestraUserToId`, `MxCommandKind.GetSessionState`, `MxCommandKind.GetWorkerInfo`, `GalaxyRepository.TestConnection`, `GalaxyRepository.GetLastDeployTime`, `GalaxyRepository.DiscoverHierarchy`, `GalaxyRepository.WatchDeployEvents`, `GalaxyRepository.BrowseChildren` |
|
||||||
| `Admin` | `admin` | `MxCommandKind.ShutdownWorker`, the default for any unrecognized request type, and the dashboard authorization policy |
|
| `Admin` | `admin` | `MxCommandKind.ShutdownWorker` and the default for any unrecognized request type |
|
||||||
|
|
||||||
The `Admin` constant is also referenced by `DashboardAuthenticator` and `DashboardAuthorizationHandler` so that the dashboard and the gRPC layer agree on what "admin" means.
|
The gRPC `admin` scope here is **distinct** from the dashboard's `Administrator` role. The scope gates API-key access to admin-level RPCs; the dashboard role gates interactive cookie-authenticated dashboard pages. `DashboardAuthorizationHandler` and the dashboard policies authorize against the `Administrator`/`Viewer` roles (see [Gateway Dashboard Design](./GatewayDashboardDesign.md)) and do not reference `GatewayScopes.Admin`. The only dashboard code that touches `GatewayScopes` is the API Keys page, which validates requested scopes against `GatewayScopes.All` when creating a key — the same validation the CLI applies.
|
||||||
|
|
||||||
## Identity Access for Downstream Layers
|
## Identity Access for Downstream Layers
|
||||||
|
|
||||||
@@ -263,14 +266,24 @@ public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollec
|
|||||||
{
|
{
|
||||||
services.AddSingleton<GatewayGrpcScopeResolver>();
|
services.AddSingleton<GatewayGrpcScopeResolver>();
|
||||||
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||||
|
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
|
||||||
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||||
|
services
|
||||||
|
.AddOptions<Grpc.AspNetCore.Server.GrpcServiceOptions>()
|
||||||
|
.Configure<IConfiguration>((grpcOptions, configuration) =>
|
||||||
|
{
|
||||||
|
ProtocolOptions protocolOptions = new();
|
||||||
|
configuration.GetSection("MxGateway:Protocol").Bind(protocolOptions);
|
||||||
|
grpcOptions.MaxReceiveMessageSize = protocolOptions.MaxGrpcMessageBytes;
|
||||||
|
grpcOptions.MaxSendMessageSize = protocolOptions.MaxGrpcMessageBytes;
|
||||||
|
});
|
||||||
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
|
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Singleton lifetimes are appropriate because none of the three classes hold per-request state on instance fields; the request-scoped value lives inside the `AsyncLocal` on `GatewayRequestIdentityAccessor`. `GatewayApplication` calls `builder.Services.AddGatewayGrpcAuthorization()` during startup, and the call also performs `AddGrpc`, so the gateway never registers gRPC without the interceptor attached.
|
Four singletons are registered: the scope resolver, the identity accessor, the constraint enforcer (`IConstraintEnforcer` → `ConstraintEnforcer`, which service bodies call to apply API-key constraints), and the interceptor itself. The same method also binds gRPC's `GrpcServiceOptions.MaxReceiveMessageSize` and `MaxSendMessageSize` from `MxGateway:Protocol:MaxGrpcMessageBytes` so the message-size limits are configured in the one place that wires the authorization pipeline. Singleton lifetimes are appropriate because none of these classes hold per-request state on instance fields; the request-scoped value lives inside the `AsyncLocal` on `GatewayRequestIdentityAccessor`. `GatewayApplication` calls `builder.Services.AddGatewayGrpcAuthorization()` during startup, and the call also performs `AddGrpc`, so the gateway never registers gRPC without the interceptor attached.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ The stable client proto manifest defines the generated-code directories:
|
|||||||
clients/dotnet/generated
|
clients/dotnet/generated
|
||||||
clients/go/internal/generated
|
clients/go/internal/generated
|
||||||
clients/rust/src/generated
|
clients/rust/src/generated
|
||||||
clients/python/src/mxgateway/generated
|
clients/python/src/zb_mom_ww_mxgateway/generated
|
||||||
clients/java/src/main/generated
|
clients/java/src/main/generated
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+42
-15
@@ -48,8 +48,8 @@ dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csp
|
|||||||
Build and test from the repository root:
|
Build and test from the repository root:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln
|
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
|
||||||
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln --no-build
|
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
|
||||||
```
|
```
|
||||||
|
|
||||||
Create local package artifacts:
|
Create local package artifacts:
|
||||||
@@ -113,7 +113,7 @@ Pop-Location
|
|||||||
|
|
||||||
## Rust
|
## Rust
|
||||||
|
|
||||||
The Rust workspace builds the `mxgateway-client` library crate and the `mxgw`
|
The Rust workspace builds the `zb-mom-ww-mxgateway-client` library crate and the `mxgw`
|
||||||
CLI crate. `build.rs` generates `tonic` and `prost` modules into Cargo build
|
CLI crate. `build.rs` generates `tonic` and `prost` modules into Cargo build
|
||||||
output on each build that needs updated protobuf output.
|
output on each build that needs updated protobuf output.
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ Pop-Location
|
|||||||
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
The Python package is `mxaccess-gateway-client`. Generated modules live under
|
The Python package is `zb-mom-ww-mxaccess-gateway-client`. Generated modules live under
|
||||||
`clients/python/src/mxgateway/generated`.
|
`clients/python/src/zb_mom_ww_mxgateway/generated`.
|
||||||
|
|
||||||
Regenerate the Python bindings:
|
Regenerate the Python bindings:
|
||||||
|
|
||||||
@@ -173,10 +173,14 @@ Install, test, and build a wheel from `clients/python`:
|
|||||||
Push-Location clients/python
|
Push-Location clients/python
|
||||||
python -m pip install -e ".[dev]"
|
python -m pip install -e ".[dev]"
|
||||||
python -m pytest
|
python -m pytest
|
||||||
python -m pip wheel . --no-deps --wheel-dir "$env:TEMP\mxgateway-python-wheel"
|
python -m build --outdir "$env:TEMP\mxgateway-python-dist"
|
||||||
Pop-Location
|
Pop-Location
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`python -m build` (sdist plus wheel) is the canonical build method — it is what
|
||||||
|
`scripts/pack-clients.ps1` runs for the Python package. Use
|
||||||
|
`python -m pip wheel . --no-deps` only for a quick wheel-only build.
|
||||||
|
|
||||||
Run the CLI from the editable install or with `python -m`:
|
Run the CLI from the editable install or with `python -m`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -184,21 +188,22 @@ Push-Location clients/python
|
|||||||
mxgw-py version --json
|
mxgw-py version --json
|
||||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||||
python -m mxgateway_cli version --json
|
python -m zb_mom_ww_mxgateway_cli version --json
|
||||||
Pop-Location
|
Pop-Location
|
||||||
```
|
```
|
||||||
|
|
||||||
## Java
|
## Java
|
||||||
|
|
||||||
The Java workspace uses Gradle, Java 21, `mxgateway-client`, and
|
The Java workspace uses Gradle, Java 21, and the subprojects
|
||||||
`mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and
|
`zb-mom-ww-mxgateway-client` and `zb-mom-ww-mxgateway-cli`. The Gradle protobuf
|
||||||
gRPC sources under `clients/java/src/main/generated`.
|
plugin writes generated Java protobuf and gRPC sources under
|
||||||
|
`clients/java/src/main/generated`.
|
||||||
|
|
||||||
Regenerate Java bindings:
|
Regenerate Java bindings:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Push-Location clients/java
|
Push-Location clients/java
|
||||||
gradle :mxgateway-client:generateProto
|
gradle :zb-mom-ww-mxgateway-client:generateProto
|
||||||
Pop-Location
|
Pop-Location
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -214,7 +219,7 @@ Create local library and CLI artifacts:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Push-Location clients/java
|
Push-Location clients/java
|
||||||
gradle :mxgateway-client:jar :mxgateway-cli:installDist
|
gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
|
||||||
Pop-Location
|
Pop-Location
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -222,12 +227,34 @@ Run the CLI through Gradle:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Push-Location clients/java
|
Push-Location clients/java
|
||||||
gradle :mxgateway-cli:run --args="version --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
|
||||||
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||||
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||||
Pop-Location
|
Pop-Location
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Packing All Clients
|
||||||
|
|
||||||
|
`scripts/pack-clients.ps1` runs every client's native packaging command and
|
||||||
|
drops the artifacts into one directory so a release does not depend on running
|
||||||
|
each per-language command by hand. It packs the .NET NuGet packages
|
||||||
|
(`ZB.MOM.WW.MxGateway.Contracts` and `ZB.MOM.WW.MxGateway.Client`), the Python
|
||||||
|
sdist and wheel (`python -m build`), the Rust `.crate` (`cargo package`), and
|
||||||
|
the Java jars plus generated POM (`gradle assemble` and the publication tasks).
|
||||||
|
Go has no artifact to pack — it is released by git-tagging, so the script prints
|
||||||
|
the `scripts/tag-go-module.ps1` command and skips it.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh scripts/pack-clients.ps1
|
||||||
|
pwsh scripts/pack-clients.ps1 -Languages dotnet,python
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts land in `-OutputDir` (default `dist/`). Each language runs its
|
||||||
|
regression tests first unless `-SkipTests` is set. With `-Publish`, every
|
||||||
|
package is pushed to the internal Gitea feed; this requires the `GITEA_USERNAME`
|
||||||
|
and `GITEA_TOKEN` environment variables and the script refuses to publish if
|
||||||
|
either is missing.
|
||||||
|
|
||||||
## Integration Tests
|
## Integration Tests
|
||||||
|
|
||||||
Client integration checks are opt-in because they need a live gateway and a
|
Client integration checks are opt-in because they need a live gateway and a
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ The manifest declares these generated-code directories:
|
|||||||
| .NET | `clients/dotnet/generated` |
|
| .NET | `clients/dotnet/generated` |
|
||||||
| Go | `clients/go/internal/generated` |
|
| Go | `clients/go/internal/generated` |
|
||||||
| Rust | `clients/rust/src/generated` |
|
| Rust | `clients/rust/src/generated` |
|
||||||
| Python | `clients/python/src/mxgateway/generated` |
|
| Python | `clients/python/src/zb_mom_ww_mxgateway/generated` |
|
||||||
| Java | `clients/java/src/main/generated` |
|
| Java | `clients/java/src/main/generated` |
|
||||||
|
|
||||||
Only generator output belongs in these directories. Handwritten client wrappers
|
Only generator output belongs in these directories. Handwritten client wrappers
|
||||||
@@ -98,7 +98,7 @@ Use these commands to regenerate language-specific client bindings:
|
|||||||
| Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` |
|
| Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` |
|
||||||
| Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` |
|
| Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` |
|
||||||
| Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` |
|
| Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` |
|
||||||
| Java | `Push-Location clients/java; gradle :mxgateway-client:generateProto; Pop-Location` |
|
| Java | `Push-Location clients/java; gradle :zb-mom-ww-mxgateway-client:generateProto; Pop-Location` |
|
||||||
|
|
||||||
.NET generation currently runs through the contracts project:
|
.NET generation currently runs through the contracts project:
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ cargo check --workspace
|
|||||||
```
|
```
|
||||||
|
|
||||||
Python clients should use `grpc_tools.protoc` and write generated modules under
|
Python clients should use `grpc_tools.protoc` and write generated modules under
|
||||||
`clients/python/src/mxgateway/generated` so imports stay separate from
|
`clients/python/src/zb_mom_ww_mxgateway/generated` so imports stay separate from
|
||||||
handwritten async wrappers.
|
handwritten async wrappers.
|
||||||
|
|
||||||
The Python scaffold provides a repo-local generation script:
|
The Python scaffold provides a repo-local generation script:
|
||||||
@@ -152,10 +152,11 @@ clients/python/generate-proto.ps1
|
|||||||
```
|
```
|
||||||
|
|
||||||
Java clients use the Gradle protobuf plugin from `clients/java`. The
|
Java clients use the Gradle protobuf plugin from `clients/java`. The
|
||||||
`mxgateway-client` project reads the shared `.proto` files and writes generated
|
`zb-mom-ww-mxgateway-client` project reads the shared `.proto` files and writes
|
||||||
Java protobuf and gRPC sources under `clients/java/src/main/generated`, matching
|
generated Java protobuf and gRPC sources under
|
||||||
the manifest output path. Handwritten client and CLI code stays in the
|
`clients/java/src/main/generated`, matching the manifest output path.
|
||||||
`mxgateway-client` and `mxgateway-cli` project source trees.
|
Handwritten client and CLI code stays in the `zb-mom-ww-mxgateway-client` and
|
||||||
|
`zb-mom-ww-mxgateway-cli` project source trees.
|
||||||
|
|
||||||
Run the Java workspace checks from `clients/java`:
|
Run the Java workspace checks from `clients/java`:
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,44 @@ only and does not share types with `mxaccess_gateway.proto`. See
|
|||||||
[Galaxy Repository Browse](./GalaxyRepository.md) for the RPC catalog and
|
[Galaxy Repository Browse](./GalaxyRepository.md) for the RPC catalog and
|
||||||
behavior.
|
behavior.
|
||||||
|
|
||||||
|
### Alarm RPCs and messages
|
||||||
|
|
||||||
|
`mxaccess_gateway.proto` also defines three session-less alarm RPCs served by
|
||||||
|
the gateway's always-on central alarm monitor (no client worker session is
|
||||||
|
involved):
|
||||||
|
|
||||||
|
- `AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply)` —
|
||||||
|
acknowledges one alarm by its `alarm_full_reference`, with an operator
|
||||||
|
`comment` and `operator_user`.
|
||||||
|
- `StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage)` — the
|
||||||
|
central alarm feed.
|
||||||
|
- `QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream
|
||||||
|
ActiveAlarmSnapshot)` — a point-in-time snapshot of the currently-active
|
||||||
|
alarm set, streamed so callers can begin processing without buffering the
|
||||||
|
whole set. `alarm_filter_prefix` (when non-empty) narrows the snapshot to
|
||||||
|
alarms whose `alarm_full_reference` starts with the prefix.
|
||||||
|
|
||||||
|
`StreamAlarms` uses a three-phase protocol carried by the `AlarmFeedMessage`
|
||||||
|
`oneof payload`: the stream opens with one `active_alarm` (`ActiveAlarmSnapshot`)
|
||||||
|
per currently-active alarm, then a single `snapshot_complete = true` sentinel,
|
||||||
|
then a `transition` (`OnAlarmTransitionEvent`) for every subsequent change.
|
||||||
|
`active_alarm` carries the collapsed current state (`AlarmConditionState`:
|
||||||
|
`Active` / `ActiveAcked` / `Inactive`); `transition` carries the
|
||||||
|
`AlarmTransitionKind` (`Raise` / `Acknowledge` / `Clear` / `Retrigger`).
|
||||||
|
|
||||||
|
`AcknowledgeAlarmRequest` and `AcknowledgeAlarmReply` both **reserve** field 1
|
||||||
|
and the name `session_id`: acknowledgement was made session-less and the field
|
||||||
|
was retired (the reservation prevents reuse of the tag). The authoritative
|
||||||
|
ack-outcome field on `AcknowledgeAlarmReply` is `hresult` (the worker's native
|
||||||
|
by-name/by-GUID ack return code, 0 = success), alongside `protocol_status`. The
|
||||||
|
structured `MxStatusProxy status` field is intentionally left **unset** on every
|
||||||
|
reply because the worker ack path produces only the int32 return code; clients
|
||||||
|
must read `hresult` and must not depend on `status` being populated.
|
||||||
|
|
||||||
|
For the broker architecture and the parse contract for `alarm_full_reference`
|
||||||
|
(GUID vs `Provider!Group.Tag`) see
|
||||||
|
[Alarm Client Discovery](./AlarmClientDiscovery.md).
|
||||||
|
|
||||||
Generated C# output is written to `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`. Do not
|
Generated C# output is written to `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`. Do not
|
||||||
hand-edit generated files.
|
hand-edit generated files.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ The shared inputs are:
|
|||||||
The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's
|
The commands in the matrix use `MXGATEWAY_API_KEY` through each CLI's
|
||||||
`api-key-env` flag. They must not embed bearer tokens or raw API keys.
|
`api-key-env` flag. They must not embed bearer tokens or raw API keys.
|
||||||
|
|
||||||
|
### TLS variant
|
||||||
|
|
||||||
|
The matrix runs over plaintext (`h2c`) by default. A TLS variant exists but stays
|
||||||
|
a manual/opt-in run, consistent with the gate above, because it needs the gateway
|
||||||
|
started with an HTTPS endpoint (an `https://` `MXGATEWAY_ENDPOINT`) and each CLI
|
||||||
|
switched to its TLS flag (`--tls` / `-tls` / `--plaintext=false` /
|
||||||
|
`plaintext=False`). The clients are lenient by default and accept the gateway's
|
||||||
|
auto-generated self-signed certificate without extra trust setup, except the Rust
|
||||||
|
CLI, which is pin-only and needs `--ca-file` or `--require-certificate-validation`
|
||||||
|
(and Python uses trust-on-first-use). See
|
||||||
|
[Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||||
|
and each client README for the per-client TLS flags.
|
||||||
|
|
||||||
## JSON Comparison
|
## JSON Comparison
|
||||||
|
|
||||||
Every command in the matrix requests JSON output. A runner can compare the
|
Every command in the matrix requests JSON output. A runner can compare the
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ operations-focused projects.
|
|||||||
|
|
||||||
The dashboard is an operational interface, not a landing page. It prioritizes
|
The dashboard is an operational interface, not a landing page. It prioritizes
|
||||||
fast scanning, low visual noise, and stable layouts while live data changes.
|
fast scanning, low visual noise, and stable layouts while live data changes.
|
||||||
The design uses Bootstrap for common behavior and a small local stylesheet for
|
The layout chrome, status presentation, and design tokens come from the shared
|
||||||
project identity, spacing, and status presentation.
|
`ZB.MOM.WW.Theme` kit (the technical-light design system). Bootstrap supplies
|
||||||
|
common widget behavior, and a small local stylesheet (`wwwroot/css/site.css`)
|
||||||
|
wires the dashboard's own class names and Bootstrap widgets onto the kit's
|
||||||
|
tokens. The local sheet contains no hard-coded colors; every color, font, and
|
||||||
|
surface resolves to a theme token.
|
||||||
|
|
||||||
Use this style for applications where users repeatedly check system state,
|
Use this style for applications where users repeatedly check system state,
|
||||||
compare rows, inspect details, and diagnose faults. Avoid promotional layouts,
|
compare rows, inspect details, and diagnose faults. Avoid promotional layouts,
|
||||||
@@ -25,7 +29,7 @@ The interface uses a quiet, work-focused visual system:
|
|||||||
- White cards and sections carry the actual operational content.
|
- White cards and sections carry the actual operational content.
|
||||||
- Borders define structure more often than shadows.
|
- Borders define structure more often than shadows.
|
||||||
- Accent color is reserved for metric values and important numeric signals.
|
- Accent color is reserved for metric values and important numeric signals.
|
||||||
- Bootstrap status badges provide state color without custom status art.
|
- The kit's `StatusPill` provides state color without custom status art.
|
||||||
- Tables remain compact and responsive so long identifiers and timestamps stay
|
- Tables remain compact and responsive so long identifiers and timestamps stay
|
||||||
readable.
|
readable.
|
||||||
|
|
||||||
@@ -34,93 +38,113 @@ and dense enough for repeated use.
|
|||||||
|
|
||||||
## Layout Structure
|
## Layout Structure
|
||||||
|
|
||||||
Every page follows the same structure:
|
The application chassis is the kit's `ThemeShell` component (a vertical side
|
||||||
|
rail plus a content area), not a horizontal top navbar. `MainLayout.razor` is a
|
||||||
|
thin wrapper that delegates the rail chassis — brand block, hamburger toggle,
|
||||||
|
responsive collapse — to `<ThemeShell>` and supplies only the navigation items
|
||||||
|
and a rail footer:
|
||||||
|
|
||||||
1. A top navigation bar with the product or service name on the left.
|
```razor
|
||||||
2. A full-width `container-fluid` content area.
|
<ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
|
||||||
3. A page header with the page title, short context text, and optional status
|
<Nav>
|
||||||
badge.
|
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
|
||||||
4. Metric cards when a page has top-level numeric state.
|
<NavRailSection Title="Runtime" Key="runtime">
|
||||||
5. Bordered content sections for tables, details, faults, or empty states.
|
<NavRailItem Href="/sessions" Text="Sessions" />
|
||||||
|
<NavRailItem Href="/workers" Text="Workers" />
|
||||||
The shell does not use a sidebar. A horizontal navigation bar is enough for the
|
</NavRailSection>
|
||||||
current page count and keeps the content width available for tables.
|
</Nav>
|
||||||
|
<RailFooter><!-- user name + sign-out --></RailFooter>
|
||||||
```html
|
<ChildContent>@Body</ChildContent>
|
||||||
<div class="dashboard-shell">
|
</ThemeShell>
|
||||||
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
|
|
||||||
<!-- brand, page links, sign-out action -->
|
|
||||||
</nav>
|
|
||||||
<main class="container-fluid dashboard-content">
|
|
||||||
<!-- page header, metric grid, sections -->
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Within the content area, every page follows the same structure:
|
||||||
|
|
||||||
|
1. A page header with the page title, short context text, and optional status
|
||||||
|
pill.
|
||||||
|
2. Metric cards when a page has top-level numeric state.
|
||||||
|
3. Bordered content sections for tables, details, faults, or empty states.
|
||||||
|
|
||||||
|
The login page uses `LoginLayout.razor` instead — a minimal layout with no rail
|
||||||
|
and no brand block, because the page renders its own centered `<LoginCard>`.
|
||||||
|
|
||||||
## Color Tokens
|
## Color Tokens
|
||||||
|
|
||||||
Use a small token set and let Bootstrap provide the rest. The current dashboard
|
Colors come from the `ZB.MOM.WW.Theme` kit's `theme.css`. The local
|
||||||
uses these local tokens:
|
`site.css` defines no `:root` custom properties of its own; it references kit
|
||||||
|
tokens by name. The dashboard does not define a `--mxgw-*` token set.
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--mxgw-surface: #f7f8fa;
|
|
||||||
--mxgw-border: #d8dee6;
|
|
||||||
--mxgw-ink-muted: #667085;
|
|
||||||
--mxgw-accent: #146c64;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Token | Purpose |
|
| Token | Purpose |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `--mxgw-surface` | Page background behind all content. |
|
| `var(--card)` | Background of cards, sections, and data tables. |
|
||||||
| `--mxgw-border` | Borders on cards, tables, sections, and empty states. |
|
| `var(--rule)`, `var(--rule-strong)` | Hairline and stronger borders. |
|
||||||
| `--mxgw-ink-muted` | Secondary labels, details, and empty-state text. |
|
| `var(--ink)`, `var(--ink-soft)`, `var(--ink-faint)` | Primary, secondary, and muted text. |
|
||||||
| `--mxgw-accent` | Metric values and important numeric summaries. |
|
| `var(--accent)`, `var(--accent-deep)` | Metric values, links, primary buttons, focus rings. |
|
||||||
|
| `var(--mono)` | Monospace family for values, identifiers, and code. |
|
||||||
|
| `var(--ok)`/`--ok-bg`, `var(--warn)`/`--warn-bg`, `var(--bad)`/`--bad-bg`, `var(--idle)`/`--idle-bg` | State colors for chips, alerts, and alarm-state labels. |
|
||||||
|
|
||||||
Keep the palette small. Add new colors only when they encode state or improve
|
Keep the palette small and let the kit own it. Add new colors only when they
|
||||||
readability. Prefer Bootstrap badge classes for states such as ready, closing,
|
encode state or improve readability, and resolve them to a kit token rather than
|
||||||
closed, and faulted.
|
a literal hex value. Use the kit's `StatusPill` for states such as ready,
|
||||||
|
closing, idle, and faulted.
|
||||||
|
|
||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
Typography stays compact and consistent:
|
Typography stays compact and consistent:
|
||||||
|
|
||||||
- Page headings use `1.35rem`, weight `650`, and normal letter spacing.
|
- Page headings (`.dashboard-page-header h1`) use `1.15rem`, weight `600`, and a
|
||||||
- Section headings use the same size as page headings when they introduce a
|
slight letter spacing.
|
||||||
table or details group.
|
- Section headings (`.section-heading h2`) use a small uppercase eyebrow:
|
||||||
- Metric labels use uppercase text at `.78rem` and weight `650`.
|
`.74rem`, weight `600`, muted ink.
|
||||||
- Metric values use `1.7rem`, weight `700`, and the accent color.
|
- Metric labels (`.agg-label`) use uppercase text at `.68rem` and weight `600`,
|
||||||
|
muted ink.
|
||||||
|
- Metric values (`.agg-value`) use `1.5rem`, weight `600`, the monospace family,
|
||||||
|
tabular numerics, and primary ink (`var(--ink)`).
|
||||||
- Body and table text inherit Bootstrap defaults for readability.
|
- Body and table text inherit Bootstrap defaults for readability.
|
||||||
|
|
||||||
Do not scale text with viewport width. Long values use `overflow-wrap:
|
Do not scale text with viewport width. Long values use `overflow-wrap:
|
||||||
anywhere` so session IDs, paths, and fault messages do not break the layout.
|
break-word` (numbers and date tokens stay whole, wrapping only at spaces); a few
|
||||||
|
free-form fields such as `.agg-sub` use `overflow-wrap: anywhere` so session
|
||||||
|
IDs, paths, and fault messages do not break the layout.
|
||||||
|
|
||||||
## Spacing And Shape
|
## Spacing And Shape
|
||||||
|
|
||||||
The dashboard uses modest spacing:
|
The dashboard uses modest spacing:
|
||||||
|
|
||||||
- Page content has `1.25rem` padding on desktop and `.75rem` on small screens.
|
- The kit owns the rail and content padding; the local small-screen rule sets
|
||||||
|
`.page` padding to `.85rem`.
|
||||||
- Metric grids use `.75rem` gaps.
|
- Metric grids use `.75rem` gaps.
|
||||||
- Content sections start with a top border and `1rem` top padding.
|
- Content sections (`.dashboard-section`) and metric cards (`.agg-card`) are
|
||||||
- Cards and empty states use Bootstrap's small radius shape, `.375rem`.
|
fully bordered cards: `var(--card)` fill, a `1px solid var(--rule)` hairline,
|
||||||
- Metric cards have no shadow.
|
and `0.9rem` padding for sections.
|
||||||
|
- Cards, sections, and modals use an `8px` radius; smaller widgets such as the
|
||||||
|
empty state use `6px`.
|
||||||
|
- Metric cards have no shadow (`box-shadow: none`); borders define structure.
|
||||||
|
|
||||||
This keeps information grouped without turning each section into a decorative
|
This keeps information grouped without turning each section into a decorative
|
||||||
panel. Use cards for repeated metric summaries, login forms, and individual
|
panel. Use cards for repeated metric summaries, login forms, and individual
|
||||||
items. Use unframed sections with a top border for page-level groups.
|
items. Use bordered sections for page-level groups.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Navigation is a Bootstrap responsive navbar. It includes:
|
Navigation lives in the `ThemeShell` side rail. It is built from the kit's
|
||||||
|
`NavRailSection` and `NavRailItem` components: a single home item plus eight
|
||||||
|
page items grouped into three labeled sections.
|
||||||
|
|
||||||
- Brand text for the service name.
|
| Section | Items |
|
||||||
- Short page labels: `Overview`, `Sessions`, `Workers`, `Events`, `Settings`.
|
|---------|-------|
|
||||||
- Active route styling through `NavLink`.
|
| (home) | `Dashboard` (route `/`, `NavLinkMatch.All`) |
|
||||||
- A right-aligned sign-out button when authentication is enabled.
|
| Runtime | `Sessions`, `Workers`, `Events`, `Alarms` |
|
||||||
|
| Galaxy | `Repository`, `Browse` |
|
||||||
|
| Admin | `API Keys`, `Settings` |
|
||||||
|
|
||||||
Keep navigation labels short. Operational users should be able to predict what
|
Section expand/collapse state is owned by the kit (a `<details>` element plus
|
||||||
each page contains without reading explanatory copy.
|
`ThemeScripts`); the layout does not run JS interop for it. The rail footer
|
||||||
|
shows the signed-in user name and a sign-out form (or a sign-in link when
|
||||||
|
unauthenticated).
|
||||||
|
|
||||||
|
Keep navigation labels short and group related pages. Operational users should
|
||||||
|
be able to predict what each page contains without reading explanatory copy.
|
||||||
|
|
||||||
## Page Headers
|
## Page Headers
|
||||||
|
|
||||||
@@ -128,42 +152,43 @@ Each page starts with a `dashboard-page-header`:
|
|||||||
|
|
||||||
- The title is the primary anchor.
|
- The title is the primary anchor.
|
||||||
- A single secondary line gives timestamp, row count, or configuration context.
|
- A single secondary line gives timestamp, row count, or configuration context.
|
||||||
- A status badge appears on the right when the page has an overall state.
|
- A status pill appears on the right when the page has an overall state.
|
||||||
|
|
||||||
On narrow screens, the header stacks vertically. This prevents long context
|
On narrow screens, the header stacks vertically. This prevents long context
|
||||||
text or status badges from overlapping the title.
|
text or status pills from overlapping the title.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div class="dashboard-page-header">
|
<div class="dashboard-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Overview</h1>
|
<h1>Dashboard</h1>
|
||||||
<div class="text-secondary">Generated 2026-04-27 17:30:00</div>
|
<div class="text-secondary">Generated 2026-04-27 17:30:00</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge text-bg-success">Healthy</span>
|
<!-- <StatusBadge Text="Healthy" /> -> kit <StatusPill State="Ok"> -->
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Metric Cards
|
## Metric Cards
|
||||||
|
|
||||||
Metric cards summarize numeric state at the top of overview and diagnostic
|
Metric cards summarize numeric state at the top of the home and diagnostic
|
||||||
pages. They use Bootstrap cards with a local `metric-card` class:
|
pages. The `MetricCard` component renders an `.agg-card` with label, value, and
|
||||||
|
optional sub-line:
|
||||||
|
|
||||||
- Label: uppercase, muted, compact.
|
- Label (`.agg-label`): uppercase eyebrow, muted, compact.
|
||||||
- Value: large enough to scan, accent colored, wraps safely.
|
- Value (`.agg-value`): large monospace number in primary ink, wraps safely.
|
||||||
- Detail: optional muted text for version, rate context, or explanatory state.
|
- Sub (`.agg-sub`): optional muted text for version, rate context, or state.
|
||||||
|
|
||||||
Use auto-fit CSS grid tracks so the cards fill available width without custom
|
Cards lay out in a `.metric-grid`. Use auto-fill CSS grid tracks so they fill
|
||||||
breakpoints:
|
available width without custom breakpoints:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid.compact {
|
.metric-grid.compact {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -188,15 +213,22 @@ entire rows clickable when a single identifier link is clearer.
|
|||||||
|
|
||||||
## Status Badges
|
## Status Badges
|
||||||
|
|
||||||
Status uses Bootstrap badge classes with a small mapping layer:
|
`StatusBadge` is a thin adapter over the kit's `StatusPill`. Call sites pass the
|
||||||
|
literal domain state text (`<StatusBadge Text="Ready" />`); the adapter maps
|
||||||
|
that text to one of the kit's four `StatusState` values, and `StatusPill`
|
||||||
|
renders the chip. There are no Bootstrap `text-bg-*` classes in this layer.
|
||||||
|
|
||||||
| State | Badge class |
|
| Domain state text | `StatusState` |
|
||||||
|-------|-------------|
|
|-------------------|---------------|
|
||||||
| `Ready`, `Healthy` | `text-bg-success` |
|
| `Ready`, `Healthy`, `Active` | `Ok` |
|
||||||
| `Creating`, `StartingWorker`, `WaitingForPipe`, `InitializingWorker`, `Closing` | `text-bg-info` |
|
| `Creating`, `StartingWorker`, `WaitingForPipe`, `InitializingWorker`, `Closing`, `Stale`, `Degraded` | `Warn` |
|
||||||
| `Closed` | `text-bg-secondary` |
|
| `Faulted`, `Unavailable` | `Bad` |
|
||||||
| `Faulted` | `text-bg-danger` |
|
| Any other text (including `Closed`, `Revoked`, `Unknown`) | `Idle` |
|
||||||
| Unknown state | `text-bg-light text-dark border` |
|
|
||||||
|
Note the mapping changes from earlier revisions: `Closed` now falls through to
|
||||||
|
`Idle` (rather than its own neutral badge), and `Active`, `Stale`, `Degraded`,
|
||||||
|
and `Unavailable` are explicit cases. The kit owns the chip rendering; only this
|
||||||
|
domain text-to-state vocabulary lives in the app.
|
||||||
|
|
||||||
Keep status text literal. Operators benefit from seeing the same state names
|
Keep status text literal. Operators benefit from seeing the same state names
|
||||||
that appear in logs and APIs.
|
that appear in logs and APIs.
|
||||||
@@ -230,8 +262,8 @@ The dashboard uses one small-screen breakpoint:
|
|||||||
|
|
||||||
```css
|
```css
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.dashboard-content {
|
.page {
|
||||||
padding: .75rem;
|
padding: .85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-page-header {
|
.dashboard-page-header {
|
||||||
@@ -245,6 +277,9 @@ The dashboard uses one small-screen breakpoint:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A second breakpoint (`max-width: 960px`) collapses the Browse two-pane layout
|
||||||
|
(`.browse-layout`) to a single column.
|
||||||
|
|
||||||
Do not hide important columns by default. Use horizontal table scrolling for
|
Do not hide important columns by default. Use horizontal table scrolling for
|
||||||
dense operational data, and reserve column hiding for data that is clearly
|
dense operational data, and reserve column hiding for data that is clearly
|
||||||
duplicative.
|
duplicative.
|
||||||
@@ -277,18 +312,19 @@ markup.
|
|||||||
|
|
||||||
Use this checklist when applying the design to another project:
|
Use this checklist when applying the design to another project:
|
||||||
|
|
||||||
- Define four local tokens: surface, border, muted ink, and accent.
|
- Take colors, fonts, and surfaces from the `ZB.MOM.WW.Theme` kit tokens; do
|
||||||
- Use a Bootstrap top navbar with short route labels.
|
not define a local color token set.
|
||||||
- Keep page content inside a full-width fluid container.
|
- Use the kit's `ThemeShell` side rail with `NavRailSection`/`NavRailItem` and
|
||||||
|
short route labels grouped into sections.
|
||||||
- Start every page with the same header structure.
|
- Start every page with the same header structure.
|
||||||
- Put primary numeric state in `metric-grid` cards.
|
- Put primary numeric state in `metric-grid` / `agg-card` cards.
|
||||||
- Put detailed runtime state in compact responsive tables.
|
- Put detailed runtime state in compact responsive tables.
|
||||||
- Use status badges mapped from real domain states.
|
- Use `StatusBadge` (kit `StatusPill`) mapped from real domain states.
|
||||||
- Use dashed bordered empty states for loading and no-data cases.
|
- Use dashed bordered empty states for loading and no-data cases.
|
||||||
- Use top-bordered sections for page groups instead of nested cards.
|
- Use top-bordered sections for page groups instead of nested cards.
|
||||||
- Centralize formatting and redaction outside Razor markup.
|
- Centralize formatting and redaction outside Razor markup.
|
||||||
- Hide every destructive admin affordance from viewers; render it only for
|
- Hide every destructive admin affordance from viewers; render it only for
|
||||||
the `Admin` role and re-check the role server-side on every invocation.
|
the `Administrator` role and re-check the role server-side on every invocation.
|
||||||
- Route every destructive action (Close session, Kill worker, Rotate /
|
- Route every destructive action (Close session, Kill worker, Rotate /
|
||||||
Revoke / Delete API key) through the shared `ConfirmDialog` component so
|
Revoke / Delete API key) through the shared `ConfirmDialog` component so
|
||||||
the operator always gets one explicit confirmation step before the call
|
the operator always gets one explicit confirmation step before the call
|
||||||
|
|||||||
+59
-4
@@ -357,10 +357,65 @@ Allowed UI stack:
|
|||||||
|
|
||||||
Do not use MudBlazor or other Blazor UI component libraries for v1.
|
Do not use MudBlazor or other Blazor UI component libraries for v1.
|
||||||
|
|
||||||
Dashboard access should require API-key-backed dashboard authentication with
|
Dashboard authentication is LDAP-backed, deliberately separate from the gRPC
|
||||||
`admin` scope when enabled. For local development, anonymous localhost access
|
API-key model: dashboard users are people who already have directory accounts,
|
||||||
is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is
|
so reusing LDAP avoids minting and distributing API keys for human operators.
|
||||||
limited to loopback requests.
|
`DashboardAuthenticator` binds the supplied credentials against `MxGateway:Ldap`
|
||||||
|
through the shared `ILdapAuthService`, then maps the user's LDAP groups to the
|
||||||
|
`Administrator` or `Viewer` dashboard role via `MxGateway:Dashboard:GroupToRole`.
|
||||||
|
A login whose groups match no role is denied. For local development, anonymous
|
||||||
|
localhost access is enabled by default through
|
||||||
|
`MxGateway:Dashboard:AllowAnonymousLocalhost`; the bypass is limited to loopback
|
||||||
|
requests.
|
||||||
|
|
||||||
|
## Lazy Browse Is Wire-Only
|
||||||
|
|
||||||
|
Decision: the gateway continues to pull the full Galaxy hierarchy on each
|
||||||
|
deploy. `BrowseChildren` and the lazy dashboard render only avoid sending and
|
||||||
|
DOM-materializing the full tree — they do not push laziness into SQL or cache
|
||||||
|
loading.
|
||||||
|
|
||||||
|
Rationale: snapshot persistence and the dashboard summary both depend on a
|
||||||
|
fully-materialized cache. Lazy SQL would increase per-click latency on a
|
||||||
|
deployment-heavy box, multiply per-session SQL connections, and complicate the
|
||||||
|
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
|
||||||
|
replies and a heavy DOM) without disturbing the materialization model.
|
||||||
|
|
||||||
|
## TLS Auto-Certificate and Lenient Client Trust
|
||||||
|
|
||||||
|
Decision: when a Kestrel `https://` endpoint is configured without a certificate
|
||||||
|
of its own (and no `Kestrel:Certificates:Default` is set), the gateway generates
|
||||||
|
and persists a self-signed certificate rather than failing to start. Clients
|
||||||
|
connecting over TLS without a pinned CA accept whatever certificate the server
|
||||||
|
presents by default; pinning a CA restores full verification.
|
||||||
|
|
||||||
|
Rationale: `mxaccessgw` is an internal tool with no PKI to issue or distribute
|
||||||
|
certificates. The prior behavior — an `https` endpoint with no certificate
|
||||||
|
fails at startup with Kestrel's opaque "no server certificate was specified"
|
||||||
|
error — pushed operators toward plaintext (`h2c`), exposing the API key and
|
||||||
|
request payloads on the wire. Auto-generating a long-lived, persisted, reused
|
||||||
|
certificate lets TLS "just work" with zero certificate management, while the
|
||||||
|
lenient client default means clients connect to that self-signed certificate
|
||||||
|
without a manual trust step. Both choices are deliberate, not oversights:
|
||||||
|
strict-by-default would force PKI work this tool does not warrant. Plaintext-only
|
||||||
|
deployments are untouched — no certificate or key material is written for them —
|
||||||
|
and an operator who supplies a real certificate transparently overrides the
|
||||||
|
generated one.
|
||||||
|
|
||||||
|
Two clients diverge from "accept any certificate" because their gRPC stacks lack
|
||||||
|
a per-channel skip-verify hook:
|
||||||
|
|
||||||
|
- Python uses trust-on-first-use: it fetches the server's presented certificate
|
||||||
|
over a separate unverified probe and pins it for the channel, and defaults the
|
||||||
|
SNI/target-name override to `localhost` (the generated certificate always
|
||||||
|
carries a `localhost` SAN).
|
||||||
|
- Rust is pin-only: tonic exposes no public hook to inject a custom certificate
|
||||||
|
verifier, so TLS over Rust requires either a pinned CA or an explicit opt-in to
|
||||||
|
system-trust verification; otherwise connecting returns a clear, actionable
|
||||||
|
error.
|
||||||
|
|
||||||
|
See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||||
|
and the per-client READMEs for the as-built behavior.
|
||||||
|
|
||||||
## Later Revisit Items
|
## Later Revisit Items
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -162,7 +162,7 @@ public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicatio
|
|||||||
{
|
{
|
||||||
ILogger logger = context.RequestServices
|
ILogger logger = context.RequestServices
|
||||||
.GetRequiredService<ILoggerFactory>()
|
.GetRequiredService<ILoggerFactory>()
|
||||||
.CreateLogger("ZB.MOM.WW.MxGateway.Request");
|
.CreateLogger("MxGateway.Request");
|
||||||
|
|
||||||
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
|
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
|
||||||
SessionId: ReadHeader(context, SessionIdHeaderName),
|
SessionId: ReadHeader(context, SessionIdHeaderName),
|
||||||
@@ -188,7 +188,7 @@ The scope is keyed off four custom headers and the standard `authorization` head
|
|||||||
|
|
||||||
The numeric headers use `int.TryParse` and `ulong.TryParse`; missing or unparseable values become `null` and are dropped by `GatewayLogScope.ToDictionary`. This keeps the middleware tolerant of clients that do not yet emit every header, which matters because the earliest call in a session (`OpenSession`) has no `SessionId` to send.
|
The numeric headers use `int.TryParse` and `ulong.TryParse`; missing or unparseable values become `null` and are dropped by `GatewayLogScope.ToDictionary`. This keeps the middleware tolerant of clients that do not yet emit every header, which matters because the earliest call in a session (`OpenSession`) has no `SessionId` to send.
|
||||||
|
|
||||||
The logger category is `ZB.MOM.WW.MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories.
|
The logger category is `MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories.
|
||||||
|
|
||||||
### Pipeline ordering
|
### Pipeline ordering
|
||||||
|
|
||||||
@@ -205,13 +205,38 @@ app.MapGatewayEndpoints();
|
|||||||
|
|
||||||
The order matters: putting the logging scope first ensures that authentication failures, authorization denials, and endpoint exceptions all run inside the request scope, so failure logs still carry the correlation id and session id headers that the caller sent. The `ClientIdentity` field is redacted before logging, so reading the `authorization` header at this stage does not leak the bearer secret into authentication failure logs.
|
The order matters: putting the logging scope first ensures that authentication failures, authorization denials, and endpoint exceptions all run inside the request scope, so failure logs still carry the correlation id and session id headers that the caller sent. The `ClientIdentity` field is redacted before logging, so reading the `authorization` header at this stage does not leak the bearer secret into authentication failure logs.
|
||||||
|
|
||||||
|
### Telemetry redaction seam
|
||||||
|
|
||||||
|
The per-request middleware redacts the `authorization` header before it reaches a scope, but log events produced outside the request scope (or with credential-bearing properties attached by other enrichers) need the same protection. `GatewayLogRedactorSeam` adapts the static `GatewayLogRedactor` to the shared `ILogRedactor` seam so the telemetry `RedactionEnricher` masks identity material on **every** log event:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
|
||||||
|
```
|
||||||
|
|
||||||
|
The seam scans a fixed set of identity-bearing property names (`ClientIdentity`, `authorization`, `Authorization`) and rewrites any string value through `GatewayLogRedactor.RedactClientIdentity`. Because it runs in the enricher rather than at the call site, it catches credential material that a component logged without going through `GatewayLogScope`.
|
||||||
|
|
||||||
|
## Readiness Health Check
|
||||||
|
|
||||||
|
`AuthStoreHealthCheck` is a readiness probe registered under the health-check name `auth-store` and tagged for the readiness set (`ZbHealthTags.Ready`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
||||||
|
"auth-store",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready });
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway authenticates every gRPC call against the SQLite auth store, so its reachability gates readiness. The check opens a connection via `AuthSqliteConnectionFactory` and runs `SELECT 1;`: success reports `Healthy`, any exception (other than the probe being cancelled) reports `Unhealthy` with the underlying error attached. It is surfaced on the readiness endpoint exposed by the shared telemetry wiring (the live/ready split is what the `wonder-app-vd03` deployment exposes as `/health/live` with the dashboard disabled).
|
||||||
|
|
||||||
## Consumers
|
## Consumers
|
||||||
|
|
||||||
`GatewayLoggerExtensions.BeginGatewayScope` is consumed by `GatewayRequestLoggingMiddlewareExtensions` to attach the per-request scope. Component-level call sites build narrower `GatewayLogScope` instances (for example, with a known `WorkerProcessId` after a worker launch) and push a nested scope on top of the request scope.
|
`GatewayLoggerExtensions.BeginGatewayScope` is consumed by `GatewayRequestLoggingMiddlewareExtensions` to attach the per-request scope. Component-level call sites build narrower `GatewayLogScope` instances (for example, with a known `WorkerProcessId` after a worker launch) and push a nested scope on top of the request scope.
|
||||||
|
|
||||||
`GatewayLogRedactor` is consumed in three places:
|
`GatewayLogRedactor` is consumed in four places:
|
||||||
|
|
||||||
- `GatewayLogScope.ToDictionary` redacts `ClientIdentity` whenever a scope is materialized.
|
- `GatewayLogScope.ToDictionary` redacts `ClientIdentity` whenever a scope is materialized.
|
||||||
|
- `GatewayLogRedactorSeam.Redact` applies the same redaction to identity-bearing properties on every telemetry log event (see above).
|
||||||
- `DashboardRedactor.Redact` delegates to `RedactClientIdentity` for any value containing the `mxgw_` marker, then falls back to a marker-keyword check for fields like `password` or `token`. This keeps dashboard renders aligned with log redaction.
|
- `DashboardRedactor.Redact` delegates to `RedactClientIdentity` for any value containing the `mxgw_` marker, then falls back to a marker-keyword check for fields like `password` or `token`. This keeps dashboard renders aligned with log redaction.
|
||||||
- `ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs` covers each redaction branch, including the assertion that `WriteSecured` values stay redacted even when `valueLoggingEnabled` is true.
|
- `ZB.MOM.WW.MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs` covers each redaction branch, including the assertion that `WriteSecured` values stay redacted even when `valueLoggingEnabled` is true.
|
||||||
|
|
||||||
|
|||||||
+111
-10
@@ -36,6 +36,7 @@ The service is defined in
|
|||||||
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
|
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
|
||||||
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
|
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
|
||||||
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
|
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
|
||||||
|
| `BrowseChildren` | Returns the direct children of one parent object (or root objects when `parent` is unset). Filters mirror `DiscoverHierarchy`. Includes a per-child `has_children` hint so UIs can draw expand triangles without an extra round trip. **Served from cache.** |
|
||||||
|
|
||||||
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
|
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
|
||||||
and `page_token`; the server defaults omitted page size to 1000 objects and
|
and `page_token`; the server defaults omitted page size to 1000 objects and
|
||||||
@@ -52,6 +53,62 @@ alarm-only, historized-only, and `include_attributes = false` for a skeleton
|
|||||||
tree. All filters are applied with AND semantics, and `total_object_count`
|
tree. All filters are applied with AND semantics, and `total_object_count`
|
||||||
reports the post-filter count.
|
reports the post-filter count.
|
||||||
|
|
||||||
|
### BrowseChildren
|
||||||
|
|
||||||
|
`BrowseChildren` is an OPC UA-style lazy expand: clients that walk one level at
|
||||||
|
a time — UI trees, OPC UA address-space bridges — call it instead of paging the
|
||||||
|
full hierarchy with `DiscoverHierarchy`.
|
||||||
|
|
||||||
|
**Parent selection.** The `parent` oneof accepts `parent_gobject_id`,
|
||||||
|
`parent_tag_name`, or `parent_contained_path`. An empty oneof returns root
|
||||||
|
objects — those whose `parent_gobject_id` is 0.
|
||||||
|
|
||||||
|
**Filters.** Category ids, template-chain substring, tag-name glob, alarm-only,
|
||||||
|
historized-only, and `include_attributes` all behave identically to
|
||||||
|
`DiscoverHierarchy` and are AND-combined. One important difference applies to
|
||||||
|
`alarm_bearing_only` and `historized_only`: an ancestor that does not itself
|
||||||
|
carry a matching attribute is still returned when one of its descendants does.
|
||||||
|
This is intentional — without it a UI tree cannot navigate to the matching
|
||||||
|
leaves. `DiscoverHierarchy`'s flat-list semantics filter out such intermediate
|
||||||
|
ancestors; `BrowseChildren` retains them so the path to each match remains
|
||||||
|
traversable.
|
||||||
|
|
||||||
|
**`child_has_children` hint.** The reply carries a boolean parallel to
|
||||||
|
`children`, set true when the child has at least one matching descendant under
|
||||||
|
the same filter set. UIs can use this to decide whether to draw an expand
|
||||||
|
triangle without issuing a follow-up `BrowseChildren` call. Because the hint is
|
||||||
|
computed against the *filtered* descendant set, a branch that contains no
|
||||||
|
matching objects gets `false`, not `true`.
|
||||||
|
|
||||||
|
**Paging.** Default page size is 500; the server caps any requested size at
|
||||||
|
5000. Page tokens are the colon-delimited triple `sequence:filterSignature:offset`
|
||||||
|
— the same encoding `DiscoverHierarchy` uses. The parent selector is not a
|
||||||
|
separate token field: it is folded into `filterSignature` along with the rest of
|
||||||
|
the filter set (the projector's `ComputeFilterSignature` takes the parent id),
|
||||||
|
so a page token implicitly pins the parent. A token from a different cache
|
||||||
|
generation (`sequence` mismatch) or a different filter set (`filterSignature`
|
||||||
|
mismatch) returns `InvalidArgument`. The error messages reference
|
||||||
|
"DiscoverHierarchy page_token" because `BrowseChildren` reuses the same encoding
|
||||||
|
and validation path — if you see that wording in a `BrowseChildren` context it is
|
||||||
|
expected.
|
||||||
|
|
||||||
|
**Errors.**
|
||||||
|
|
||||||
|
| Condition | Status code |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Unknown parent | `NotFound` |
|
||||||
|
| First load not yet complete after 5 s | `Unavailable` |
|
||||||
|
| Stale or filter-mismatched page token | `InvalidArgument` |
|
||||||
|
| Missing `metadata:read` scope | `PermissionDenied` |
|
||||||
|
| No API key | `Unauthenticated` |
|
||||||
|
|
||||||
|
**Authorization.** Same `metadata:read` scope as the other Galaxy RPCs.
|
||||||
|
`browse_subtrees` API-key constraints intersect with the result set.
|
||||||
|
|
||||||
|
**Sort order.** Areas first, then `OrdinalIgnoreCase` by display name
|
||||||
|
(`browse_name` → `contained_name` → `tag_name`). Matches the dashboard tree so
|
||||||
|
server and dashboard views are consistent.
|
||||||
|
|
||||||
## Hierarchy Cache
|
## Hierarchy Cache
|
||||||
|
|
||||||
The gateway holds a single shared `IGalaxyHierarchyCache`
|
The gateway holds a single shared `IGalaxyHierarchyCache`
|
||||||
@@ -81,6 +138,15 @@ When SQL is unreachable, the cache retains the previous data and flips
|
|||||||
`Status` to `Stale` (or `Unavailable` if no data was ever loaded). A
|
`Status` to `Stale` (or `Unavailable` if no data was ever loaded). A
|
||||||
`SqlException` never bubbles out as the client-facing error.
|
`SqlException` never bubbles out as the client-facing error.
|
||||||
|
|
||||||
|
The cache also auto-degrades a `Healthy` entry to `Stale` purely on age: when the
|
||||||
|
last successful refresh is older than five minutes, the projected status is
|
||||||
|
reported as `Stale` even though the data hasn't otherwise changed. This guards
|
||||||
|
against a silently wedged refresh loop — if ticks stop succeeding, browse
|
||||||
|
results visibly go `Stale` rather than continuing to look fresh. (`Unknown` and
|
||||||
|
`Unavailable` entries are returned as-is and not aged.) The first refresh runs at
|
||||||
|
service startup, before the interval loop begins, so the cache is populated as
|
||||||
|
soon as practical rather than waiting one full interval.
|
||||||
|
|
||||||
### First-load behavior
|
### First-load behavior
|
||||||
|
|
||||||
If a client calls `DiscoverHierarchy` before the background service has
|
If a client calls `DiscoverHierarchy` before the background service has
|
||||||
@@ -104,7 +170,10 @@ working across that gap, the cache persists its dataset to disk:
|
|||||||
- On the **first** refresh after startup, before any SQL runs, the cache
|
- On the **first** refresh after startup, before any SQL runs, the cache
|
||||||
reloads that file. The restored data is served with `Stale` status —
|
reloads that file. The restored data is served with `Stale` status —
|
||||||
it is last-known data, not live — so clients can browse immediately even
|
it is last-known data, not live — so clients can browse immediately even
|
||||||
when the Galaxy database is unreachable.
|
when the Galaxy database is unreachable. The restore also publishes a deploy
|
||||||
|
event through `IGalaxyDeployNotifier`, so a `WatchDeployEvents` subscriber that
|
||||||
|
attaches before the first live query still sees the restored snapshot's deploy
|
||||||
|
state.
|
||||||
- The first live query then reconciles: if it observes the **same**
|
- The first live query then reconciles: if it observes the **same**
|
||||||
`time_of_last_deploy` the snapshot was saved at, the entry is promoted to
|
`time_of_last_deploy` the snapshot was saved at, the entry is promoted to
|
||||||
`Healthy` with no heavy re-query (the snapshot is provably current); if it
|
`Healthy` with no heavy re-query (the snapshot is provably current); if it
|
||||||
@@ -271,9 +340,13 @@ fields cannot express null. Use it to distinguish "no dimension reported" from
|
|||||||
```text
|
```text
|
||||||
gRPC client(s)
|
gRPC client(s)
|
||||||
-> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/)
|
-> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/)
|
||||||
DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current
|
DiscoverHierarchy, GetLastDeployTime, BrowseChildren -> IGalaxyHierarchyCache.Current
|
||||||
WatchDeployEvents -> IGalaxyDeployNotifier
|
WatchDeployEvents -> IGalaxyDeployNotifier
|
||||||
TestConnection -> GalaxyRepository (direct SQL)
|
TestConnection -> GalaxyRepository (direct SQL)
|
||||||
|
|
||||||
|
Dashboard (Blazor)
|
||||||
|
-> IDashboardBrowseService (DashboardBrowseService)
|
||||||
|
-> GalaxyBrowseProjector over IGalaxyHierarchyCache.Current
|
||||||
|
|
||||||
GalaxyHierarchyRefreshService (BackgroundService)
|
GalaxyHierarchyRefreshService (BackgroundService)
|
||||||
-> IGalaxyHierarchyCache.RefreshAsync
|
-> IGalaxyHierarchyCache.RefreshAsync
|
||||||
@@ -293,6 +366,25 @@ Component breakdown:
|
|||||||
override per object. `HierarchySql` still matches the OtOpcUa original;
|
override per object. `HierarchySql` still matches the OtOpcUa original;
|
||||||
`AttributesSql` does not — it additionally enumerates built-in primitive
|
`AttributesSql` does not — it additionally enumerates built-in primitive
|
||||||
attributes (see [Built-in vs configured attributes](#built-in-vs-configured-attributes)).
|
attributes (see [Built-in vs configured attributes](#built-in-vs-configured-attributes)).
|
||||||
|
|
||||||
|
`HierarchySql` restricts the result to a fixed allow-list of object categories
|
||||||
|
via `WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)` — the same set
|
||||||
|
the dashboard's `ResolveCategoryName` map names. Categories outside this set
|
||||||
|
(for example, internal framework objects) are never browsed. The mapping:
|
||||||
|
|
||||||
|
| `category_id` | Name |
|
||||||
|
|---|---|
|
||||||
|
| 1 | WinPlatform |
|
||||||
|
| 3 | AppEngine |
|
||||||
|
| 4 | InTouchViewApp |
|
||||||
|
| 10 | UserDefined |
|
||||||
|
| 11 | FieldReference |
|
||||||
|
| 13 | Area |
|
||||||
|
| 17 | DIObject |
|
||||||
|
| 24 | DDESuiteLinkClient |
|
||||||
|
| 26 | OPCClient |
|
||||||
|
|
||||||
|
Any other category id renders as `Category {id}` in the dashboard.
|
||||||
- `GalaxyHierarchyCache`
|
- `GalaxyHierarchyCache`
|
||||||
(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most
|
(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most
|
||||||
recent immutable `GalaxyHierarchyCacheEntry` (materialized objects +
|
recent immutable `GalaxyHierarchyCacheEntry` (materialized objects +
|
||||||
@@ -309,9 +401,17 @@ Component breakdown:
|
|||||||
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to
|
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to
|
||||||
proto messages. Used by the cache during refresh to materialize the reply
|
proto messages. Used by the cache during refresh to materialize the reply
|
||||||
once.
|
once.
|
||||||
|
- `GalaxyBrowseProjector`
|
||||||
|
(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`) projects one level
|
||||||
|
of children out of an immutable cache entry. Memoizes the filtered child list
|
||||||
|
per cache-entry instance so repeated paging is an O(pageSize) slice rather than an
|
||||||
|
O(siblings) filter scan. The memo is keyed on the cache entry reference, so a new
|
||||||
|
entry from the background refresh makes the stale memo unreachable and it is
|
||||||
|
collected with it. `DashboardBrowseService` wraps this projector to drive the
|
||||||
|
dashboard's lazy-expand tree.
|
||||||
- `GalaxyRepositoryGrpcService`
|
- `GalaxyRepositoryGrpcService`
|
||||||
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
|
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
|
||||||
the four RPCs.
|
the five RPCs.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -320,7 +420,7 @@ Bound to `MxGateway:Galaxy` via `GalaxyRepositoryOptions`.
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository. Integrated Security against `localhost` is the dev default; production deployments should override this through the standard double-underscore environment variable form, e.g. `MxGateway__Galaxy__ConnectionString`. |
|
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository. Integrated Security against `localhost` is the dev default; production deployments should override this through the standard double-underscore environment variable form, e.g. `MxGateway__Galaxy__ConnectionString`. |
|
||||||
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout. Applies to all three RPCs. |
|
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout applied to every SQL command the repository runs (the connectivity probe, the deploy-time poll, and the hierarchy and attribute queries), which back all five Galaxy RPCs. |
|
||||||
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists each successful browse dataset to disk and reloads it at startup. See [On-disk snapshot](#on-disk-snapshot). |
|
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists each successful browse dataset to disk and reloads it at startup. See [On-disk snapshot](#on-disk-snapshot). |
|
||||||
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted browse snapshot. Ignored when `PersistSnapshot` is `false`. |
|
| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted browse snapshot. Ignored when `PersistSnapshot` is `false`. |
|
||||||
|
|
||||||
@@ -336,7 +436,8 @@ unparsed connection string text.
|
|||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
All four Galaxy RPCs (including `WatchDeployEvents`) require the
|
All five Galaxy RPCs (`TestConnection`, `GetLastDeployTime`,
|
||||||
|
`DiscoverHierarchy`, `WatchDeployEvents`, and `BrowseChildren`) require the
|
||||||
`metadata:read` API-key scope. Browse is read-only metadata, equivalent in
|
`metadata:read` API-key scope. Browse is read-only metadata, equivalent in
|
||||||
privilege to `MxCommandKind.GetSessionState` or `MxCommandKind.GetWorkerInfo`.
|
privilege to `MxCommandKind.GetSessionState` or `MxCommandKind.GetWorkerInfo`.
|
||||||
The mapping lives in `GatewayGrpcScopeResolver`; see
|
The mapping lives in `GatewayGrpcScopeResolver`; see
|
||||||
@@ -355,17 +456,17 @@ embedded in the status detail.
|
|||||||
|
|
||||||
The gateway's Blazor dashboard surfaces a Galaxy summary in two places:
|
The gateway's Blazor dashboard surfaces a Galaxy summary in two places:
|
||||||
|
|
||||||
- An overview card on `/dashboard` showing connectivity status, last deploy
|
- An overview card on `/` showing connectivity status, last deploy
|
||||||
timestamp, object count (with area count), attribute total, historized and
|
timestamp, object count (with area count), attribute total, historized and
|
||||||
alarm counts, and last successful refresh.
|
alarm counts, and last successful refresh.
|
||||||
- A dedicated `/dashboard/galaxy` page with object-category and top-template
|
- A dedicated `/galaxy` page with object-category and top-template
|
||||||
breakdowns plus a Sync Info table covering last successful refresh, last
|
breakdowns plus a Sync Info table covering last successful refresh, last
|
||||||
attempt, refresh interval, redacted connection string, and command timeout.
|
attempt, refresh interval, redacted connection string, and command timeout.
|
||||||
|
|
||||||
Both views are projected from the same `IGalaxyHierarchyCache` that backs the
|
Both views are projected from the same `IGalaxyHierarchyCache` that backs the
|
||||||
gRPC service. The dashboard does not run its own refresh — when the
|
gRPC service. The dashboard does not run its own refresh — when the
|
||||||
background `GalaxyHierarchyRefreshService` updates the cache, both the
|
background `GalaxyHierarchyRefreshService` updates the cache, both the
|
||||||
overview card and the `/dashboard/galaxy` page pick up the new state on the
|
overview card and the `/galaxy` page pick up the new state on the
|
||||||
next dashboard tick. When SQL is unreachable, the cache retains the previous
|
next dashboard tick. When SQL is unreachable, the cache retains the previous
|
||||||
data and flips `Status` to `Stale` or `Unavailable`; the dashboard surfaces
|
data and flips `Status` to `Stale` or `Unavailable`; the dashboard surfaces
|
||||||
that as a yellow or red status badge plus the truncated error.
|
that as a yellow or red status badge plus the truncated error.
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
|||||||
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
||||||
"RunMigrationsOnStartup": true
|
"RunMigrationsOnStartup": true
|
||||||
},
|
},
|
||||||
|
"Ldap": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Server": "localhost",
|
||||||
|
"Port": 3893,
|
||||||
|
"Transport": "None",
|
||||||
|
"AllowInsecure": true,
|
||||||
|
"SearchBase": "dc=zb,dc=local",
|
||||||
|
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||||
|
"ServiceAccountPassword": "serviceaccount123",
|
||||||
|
"UserNameAttribute": "cn",
|
||||||
|
"DisplayNameAttribute": "cn",
|
||||||
|
"GroupAttribute": "memberOf"
|
||||||
|
},
|
||||||
"Worker": {
|
"Worker": {
|
||||||
"ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe",
|
"ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe",
|
||||||
"WorkingDirectory": null,
|
"WorkingDirectory": null,
|
||||||
@@ -46,12 +59,13 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
|
|||||||
"Dashboard": {
|
"Dashboard": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"AllowAnonymousLocalhost": true,
|
"AllowAnonymousLocalhost": true,
|
||||||
|
"RequireHttpsCookie": true,
|
||||||
"SnapshotIntervalMilliseconds": 1000,
|
"SnapshotIntervalMilliseconds": 1000,
|
||||||
"RecentFaultLimit": 100,
|
"RecentFaultLimit": 100,
|
||||||
"RecentSessionLimit": 200,
|
"RecentSessionLimit": 200,
|
||||||
"ShowTagValues": false,
|
"ShowTagValues": false,
|
||||||
"GroupToRole": {
|
"GroupToRole": {
|
||||||
"GwAdmin": "Admin",
|
"GwAdmin": "Administrator",
|
||||||
"GwReader": "Viewer"
|
"GwReader": "Viewer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -92,6 +106,39 @@ Environment variables use the normal .NET double-underscore form. For example,
|
|||||||
When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
|
When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
|
||||||
`SqlitePath` must be a valid filesystem path.
|
`SqlitePath` must be a valid filesystem path.
|
||||||
|
|
||||||
|
## Ldap Options
|
||||||
|
|
||||||
|
The `MxGateway:Ldap` section configures the dashboard's LDAP login (the gRPC API
|
||||||
|
uses API keys, not LDAP — see [Authentication](./Authentication.md)). The same
|
||||||
|
section is bound twice: the runtime bind/search is performed by the shared
|
||||||
|
`ZB.MOM.WW.Auth.Ldap` provider wired up by `AddZbLdapAuth`, while the gateway's
|
||||||
|
own `LdapOptions` shadow exists only for startup validation, the redacted
|
||||||
|
effective-config display, and the dev/default values. The two stay
|
||||||
|
field-compatible so the one section binds onto both. The gateway ships
|
||||||
|
dev-friendly defaults (plaintext localhost); the shared provider's own defaults
|
||||||
|
are secure-by-default.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `MxGateway:Ldap:Enabled` | `true` | Enables LDAP-backed dashboard login. When `false`, the rest of the section is not validated and LDAP login is not wired up. |
|
||||||
|
| `MxGateway:Ldap:Server` | `localhost` | LDAP server host. Required when `Enabled`. |
|
||||||
|
| `MxGateway:Ldap:Port` | `3893` | LDAP server port. Must be a valid port (1–65535). |
|
||||||
|
| `MxGateway:Ldap:Transport` | `None` | Transport/TLS mode. One of `None` (plaintext), `StartTls` (upgrade a plaintext connection to TLS), or `Ldaps` (TLS from connect). Replaces the former boolean `UseTls`. |
|
||||||
|
| `MxGateway:Ldap:AllowInsecure` | `true` | Allows plaintext LDAP connections. Must be `true` when `Transport` is `None`; setting `Transport=None` with `AllowInsecure=false` fails validation. |
|
||||||
|
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base distinguished name for user lookup. Required when `Enabled`. |
|
||||||
|
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Service account DN used to bind before searching for the logging-in user. Required when `Enabled`. Redacted in the effective-config display. |
|
||||||
|
| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Service account bind password. Required when `Enabled`. Never logged; redacted in the effective-config display. |
|
||||||
|
| `MxGateway:Ldap:UserNameAttribute` | `cn` | Attribute matched against the login user name (the dev GLAuth directory keys users by `cn`, not `uid`). Required when `Enabled`. |
|
||||||
|
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | Attribute read for the user's display name. Required when `Enabled`. |
|
||||||
|
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | Attribute read for the user's group membership. The resulting group names are mapped to dashboard roles by `MxGateway:Dashboard:GroupToRole`. Required when `Enabled`. |
|
||||||
|
|
||||||
|
When `Enabled` is `true`, `Server`, `SearchBase`, `ServiceAccountDn`,
|
||||||
|
`ServiceAccountPassword`, `UserNameAttribute`, `DisplayNameAttribute`, and
|
||||||
|
`GroupAttribute` must be non-blank, `Port` must be valid, and `AllowInsecure`
|
||||||
|
must be `true` whenever `Transport` is `None`. Group-to-role mapping lives in the
|
||||||
|
dashboard section; see `MxGateway:Dashboard:GroupToRole` below and
|
||||||
|
[glauth.md](../glauth.md).
|
||||||
|
|
||||||
## Worker Options
|
## Worker Options
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
@@ -146,11 +193,13 @@ the affected stream while the MXAccess session remains active.
|
|||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||||
|
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||||
|
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
||||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. |
|
| `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. |
|
||||||
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
|
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
|
||||||
|
|
||||||
`SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit`
|
`SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit`
|
||||||
and `RecentSessionLimit` must be greater than or equal to zero.
|
and `RecentSessionLimit` must be greater than or equal to zero.
|
||||||
@@ -163,10 +212,10 @@ users) but practical deployments populate at least one Admin group.
|
|||||||
Three authorization policies are registered out of these options:
|
Three authorization policies are registered out of these options:
|
||||||
|
|
||||||
- `MxGateway.Dashboard.Viewer` — gates the Razor component routes. Satisfied by
|
- `MxGateway.Dashboard.Viewer` — gates the Razor component routes. Satisfied by
|
||||||
either dashboard role (Admin or Viewer), by `AllowAnonymousLocalhost` on
|
either dashboard role (Administrator or Viewer), by `AllowAnonymousLocalhost` on
|
||||||
loopback, or by `Authentication.Mode = Disabled`.
|
loopback, or by `Authentication.Mode = Disabled`.
|
||||||
- `MxGateway.Dashboard.Admin` — gates write-capable surfaces (API-key CRUD).
|
- `MxGateway.Dashboard.Admin` — gates write-capable surfaces (API-key CRUD).
|
||||||
Satisfied only by the Admin role (same environmental bypasses).
|
Satisfied only by the Administrator role (same environmental bypasses).
|
||||||
- `MxGateway.Dashboard.HubClients` — attached to the SignalR hubs. Accepts
|
- `MxGateway.Dashboard.HubClients` — attached to the SignalR hubs. Accepts
|
||||||
either the dashboard cookie scheme or the `MxGateway.Dashboard.HubToken`
|
either the dashboard cookie scheme or the `MxGateway.Dashboard.HubToken`
|
||||||
bearer scheme (used by SignalR's WebSocket upgrade path where the HttpOnly
|
bearer scheme (used by SignalR's WebSocket upgrade path where the HttpOnly
|
||||||
@@ -227,6 +276,185 @@ behavior.
|
|||||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||||
|
|
||||||
|
## Host Endpoints and Transport Security (Kestrel)
|
||||||
|
|
||||||
|
The listening endpoints are **not** part of the `MxGateway` section. The gateway
|
||||||
|
uses the stock ASP.NET Core host (`WebApplication.CreateBuilder`) with no
|
||||||
|
`ConfigureKestrel` call in code, so endpoints come entirely from the standard
|
||||||
|
`Kestrel` configuration section. On the deployed hosts these values are supplied
|
||||||
|
as NSSM environment variables (`Kestrel__Endpoints__...`), not from
|
||||||
|
`appsettings.json`.
|
||||||
|
|
||||||
|
Two named endpoints are bound:
|
||||||
|
|
||||||
|
| Endpoint name | Purpose | Protocol requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| `Http` | Public gRPC API (sessions, invoke, events, Galaxy browse) | HTTP/2 |
|
||||||
|
| `Dashboard` | Blazor dashboard and SignalR hubs | HTTP/1.1 (HTTP/2 optional) |
|
||||||
|
|
||||||
|
Both endpoints share one routing pipeline; the names only select which TCP port
|
||||||
|
serves which traffic. The gRPC endpoint must negotiate **HTTP/2**, which drives
|
||||||
|
the protocol settings below.
|
||||||
|
|
||||||
|
### Plaintext (current deployments)
|
||||||
|
|
||||||
|
Both running hosts (`10.100.0.48` and `wonder-app-vd03`) serve the gRPC port in
|
||||||
|
**cleartext HTTP/2 (`h2c`)**. Because cleartext HTTP/2 has no ALPN to negotiate
|
||||||
|
the protocol, the gRPC endpoint must be pinned to `Http2` with prior knowledge:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kestrel__Endpoints__Http__Url=http://0.0.0.0:5120
|
||||||
|
Kestrel__Endpoints__Http__Protocols=Http2
|
||||||
|
Kestrel__Endpoints__Dashboard__Url=http://0.0.0.0:5130
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode all client↔gateway traffic — including the
|
||||||
|
`authorization: Bearer mxgw_...` API key and any `WriteSecured` / `AuthenticateUser`
|
||||||
|
payloads — crosses the network **unencrypted**. This is acceptable only on a
|
||||||
|
trusted/isolated network segment. Prefer TLS for anything else.
|
||||||
|
|
||||||
|
### TLS
|
||||||
|
|
||||||
|
To encrypt the gRPC channel, give the `Http` endpoint an `https://` URL and a
|
||||||
|
certificate. Over TLS, ALPN negotiates HTTP/2, so the explicit `Protocols=Http2`
|
||||||
|
pin is no longer required (the default `Http1AndHttp2` works for gRPC over TLS).
|
||||||
|
|
||||||
|
`appsettings.json` form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Kestrel": {
|
||||||
|
"Endpoints": {
|
||||||
|
"Http": {
|
||||||
|
"Url": "https://0.0.0.0:5120",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
|
||||||
|
"Password": "<pfx-password>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Dashboard": {
|
||||||
|
"Url": "https://0.0.0.0:5130",
|
||||||
|
"Certificate": {
|
||||||
|
"Path": "C:\\ProgramData\\MxGateway\\certs\\gateway.pfx",
|
||||||
|
"Password": "<pfx-password>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent NSSM environment-variable form (how config is delivered on the hosts —
|
||||||
|
see [server deploy mechanics in the project notes]):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Kestrel__Endpoints__Http__Url=https://0.0.0.0:5120
|
||||||
|
Kestrel__Endpoints__Http__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
|
||||||
|
Kestrel__Endpoints__Http__Certificate__Password=<pfx-password>
|
||||||
|
Kestrel__Endpoints__Dashboard__Url=https://0.0.0.0:5130
|
||||||
|
Kestrel__Endpoints__Dashboard__Certificate__Path=C:\ProgramData\MxGateway\certs\gateway.pfx
|
||||||
|
Kestrel__Endpoints__Dashboard__Certificate__Password=<pfx-password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Certificate sourcing options (any standard ASP.NET Core form is accepted):
|
||||||
|
|
||||||
|
| Form | Keys |
|
||||||
|
|---|---|
|
||||||
|
| PFX file | `Certificate:Path` (+ `Certificate:Password` if encrypted) |
|
||||||
|
| PEM pair | `Certificate:Path` (cert) + `Certificate:KeyPath` (private key) |
|
||||||
|
| Windows cert store | `Certificate:Subject`, `Certificate:Store` (e.g. `My`), `Certificate:Location` (`LocalMachine`), `Certificate:AllowInvalid` |
|
||||||
|
|
||||||
|
The certificate's CN/SAN must cover the host name clients dial (or clients must
|
||||||
|
set a server-name override — see below). The dashboard endpoint can keep its own
|
||||||
|
certificate independent of the gRPC endpoint; pair this with
|
||||||
|
`MxGateway:Dashboard:RequireHttpsCookie` (`true`) for production HTTPS.
|
||||||
|
|
||||||
|
### Automatic self-signed certificate
|
||||||
|
|
||||||
|
`mxaccessgw` is an internal tool with no PKI to issue certificates, so requiring
|
||||||
|
an operator to supply one before TLS works pushed deployments toward plaintext.
|
||||||
|
To avoid that, the gateway fills in a self-signed certificate when an HTTPS
|
||||||
|
endpoint is configured without one.
|
||||||
|
|
||||||
|
**Trigger.** At startup the gateway inspects `Kestrel:Endpoints:*`. If any
|
||||||
|
endpoint has an `https://` URL and no `Certificate` subsection of its own, and no
|
||||||
|
`Kestrel:Certificates:Default` is set, the gateway generates (or loads) a
|
||||||
|
persisted self-signed certificate and wires it in as the HTTPS *default* via
|
||||||
|
`ConfigureHttpsDefaults`. All-plaintext deployments are untouched: when no HTTPS
|
||||||
|
endpoint is configured, no certificate or key material is generated or written.
|
||||||
|
|
||||||
|
**Generated certificate.** ECDSA P-256, `serverAuth` EKU, validity ≈
|
||||||
|
`ValidityYears` (default 10 years, with one day of clock-skew slack before
|
||||||
|
`notBefore`). SANs cover `localhost`, the machine name (and its FQDN when
|
||||||
|
resolvable), each entry in `AdditionalDnsNames`, and the loopback addresses
|
||||||
|
`127.0.0.1` and `::1`.
|
||||||
|
|
||||||
|
**`MxGateway:Tls:*` options.** All optional; the zero-config path needs none of
|
||||||
|
them.
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
|
||||||
|
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) |
|
||||||
|
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
|
||||||
|
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
|
||||||
|
|
||||||
|
`ValidityYears` is validated by `GatewayOptionsValidator` (range 1–100); the
|
||||||
|
"HTTPS endpoint configured but no certificate available" fail-fast lives in the
|
||||||
|
bootstrap/provider, because the validator only sees the `MxGateway` section, not
|
||||||
|
`Kestrel:Endpoints`.
|
||||||
|
|
||||||
|
**Persistence.** The PFX is written with an **empty** export password — a random
|
||||||
|
in-memory password could not be reused across restarts, which the
|
||||||
|
persist-and-reuse model requires. The private key is instead protected at rest by
|
||||||
|
filesystem permissions: a restrictive ACL on Windows (SYSTEM + Administrators,
|
||||||
|
inherited ACEs stripped) on the `certs` directory and file, and mode `0600` on
|
||||||
|
non-Windows. The write is atomic (hardened temp file, then move). The persisted
|
||||||
|
certificate is reused across restarts (stable thumbprint, so CA-pinning clients
|
||||||
|
keep working) and regenerated only when it is missing, expired (and
|
||||||
|
`RegenerateIfExpired` is `true`), or unreadable/corrupt. If the directory is not
|
||||||
|
writable or the ACL cannot be applied, the gateway fails fast with a diagnostic
|
||||||
|
naming the path rather than falling back to an in-memory certificate.
|
||||||
|
|
||||||
|
**Logging.** On generate or load, the gateway logs the certificate thumbprint,
|
||||||
|
SAN list, and `notAfter` at Information. The PFX bytes, export password, and
|
||||||
|
private key are never logged.
|
||||||
|
|
||||||
|
**Operator override.** The generated certificate is only the HTTPS *default*. To
|
||||||
|
use a real certificate, configure one explicitly — either per endpoint via
|
||||||
|
`Kestrel:Endpoints:<name>:Certificate` (`Path`/`Subject`/`Thumbprint`, etc., as
|
||||||
|
in the table above) or globally via `Kestrel:Certificates:Default`. An
|
||||||
|
explicitly-configured certificate takes precedence, and the gateway then writes
|
||||||
|
no self-signed material.
|
||||||
|
|
||||||
|
### Client side
|
||||||
|
|
||||||
|
Each official client opts into TLS explicitly. For the .NET client
|
||||||
|
(`MxGatewayClientOptions`):
|
||||||
|
|
||||||
|
| Option | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `UseTls` (default `false`) | Enables TLS. Requires an `https://` endpoint; an `https://` endpoint without `UseTls` fails validation, and vice versa. |
|
||||||
|
| `CaCertificatePath` | Pins a custom root (self-signed / private CA) using `CustomRootTrust` chain validation instead of the OS trust store; the .NET client also enforces the certificate hostname/SAN match on this path. |
|
||||||
|
| `RequireCertificateValidation` (default `false`) | Forces OS/system-trust verification on a TLS connection with no pinned CA. Leave `false` for the lenient default. |
|
||||||
|
| `ServerNameOverride` | SNI / certificate host name override when the dialed host differs from the certificate CN/SAN. |
|
||||||
|
|
||||||
|
To pair with the auto-generated self-signed certificate above, the clients are
|
||||||
|
**lenient by default**: a TLS connection with no pinned CA accepts whatever
|
||||||
|
certificate the gateway presents. Pin `CaCertificatePath` to verify, or set
|
||||||
|
`RequireCertificateValidation` to force system-trust verification without
|
||||||
|
pinning. The other language clients expose the equivalent options; the exact
|
||||||
|
behavior differs per stack — Python uses trust-on-first-use and Rust is pin-only.
|
||||||
|
See each client README for the as-built behavior.
|
||||||
|
|
||||||
|
### Gateway↔worker IPC
|
||||||
|
|
||||||
|
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
|
||||||
|
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).
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
||||||
|
|||||||
+123
-41
@@ -9,11 +9,13 @@ statistics in real time.
|
|||||||
|
|
||||||
## Technology Choice
|
## Technology Choice
|
||||||
|
|
||||||
Decision: Blazor Server with Bootstrap CSS/JS.
|
Decision: Blazor Server with the shared `ZB.MOM.WW.Theme` kit layered over
|
||||||
|
Bootstrap CSS/JS.
|
||||||
|
|
||||||
Allowed UI stack:
|
Allowed UI stack:
|
||||||
|
|
||||||
- ASP.NET Core Blazor Server,
|
- ASP.NET Core Blazor Server,
|
||||||
|
- the `ZB.MOM.WW.Theme` kit (layout chassis, status components, design tokens),
|
||||||
- Bootstrap CSS,
|
- Bootstrap CSS,
|
||||||
- Bootstrap JavaScript,
|
- Bootstrap JavaScript,
|
||||||
- small local CSS for layout and status styling,
|
- small local CSS for layout and status styling,
|
||||||
@@ -30,7 +32,35 @@ Not allowed for v1:
|
|||||||
|
|
||||||
Rationale: Blazor Server keeps the dashboard in the gateway process, avoids a
|
Rationale: Blazor Server keeps the dashboard in the gateway process, avoids a
|
||||||
separate frontend build, and gives real-time UI updates through the Blazor
|
separate frontend build, and gives real-time UI updates through the Blazor
|
||||||
SignalR circuit. Bootstrap is sufficient for a basic dashboard.
|
SignalR circuit. The `ZB.MOM.WW.Theme` kit gives the dashboard the same chassis,
|
||||||
|
status vocabulary, and visual identity as the other ZB.MOM.WW operations UIs
|
||||||
|
without re-implementing layout and status styling per project.
|
||||||
|
|
||||||
|
## Theme Kit
|
||||||
|
|
||||||
|
The dashboard depends on the shared `ZB.MOM.WW.Theme` NuGet package
|
||||||
|
(version `0.2.0`, referenced in `ZB.MOM.WW.MxGateway.Server.csproj`). The kit is
|
||||||
|
a Razor Class Library that ships the technical-light design system: a layout
|
||||||
|
chassis, a small set of UI components, the design tokens, and the head/script
|
||||||
|
asset wiring. The dashboard takes its chrome and status presentation from the
|
||||||
|
kit and adds only its own pages and view CSS on top.
|
||||||
|
|
||||||
|
Components and assets used:
|
||||||
|
|
||||||
|
| Kit member | Role in the dashboard |
|
||||||
|
|---|---|
|
||||||
|
| `<ThemeShell>` | The application chassis — vertical side rail (brand, hamburger, responsive collapse) plus a content area. `MainLayout.razor` wraps it and supplies `Nav`, `RailFooter`, and `ChildContent` slots. |
|
||||||
|
| `<NavRailSection>` / `<NavRailItem>` | Grouped navigation items in the rail. Section expand/collapse persistence is owned by the kit (`<details>` + `ThemeScripts`); the app runs no JS interop for it. |
|
||||||
|
| `<LoginCard>` | The centered login card on `Login.razor`. Renders a native static `<form method="post" action="/login">` so the submit reaches the minimal-API endpoint rather than a Blazor event. |
|
||||||
|
| `<StatusPill State="…">` | The status chip. `StatusBadge.razor` is a thin adapter that maps domain state text to one of four `StatusState` values (`Ok`, `Warn`, `Bad`, `Idle`) and renders this pill. |
|
||||||
|
| `<ThemeHead/>` | Loaded in `App.razor`'s `<head>`; injects the kit's `theme.css` and related head assets. |
|
||||||
|
| `<ThemeScripts/>` | Loaded at the end of `App.razor`'s `<body>`; supplies the rail's interactive behavior. |
|
||||||
|
| Token system | `theme.css` defines all design tokens (`var(--card)`, `var(--ink)`, `var(--accent)`, `var(--mono)`, the state colors, etc.). The local `site.css` references these tokens and defines no hard-coded colors. |
|
||||||
|
|
||||||
|
The dependency on this kit is the reason the layout shell, navigation, status
|
||||||
|
chips, and tokens differ from a stock Bootstrap dashboard. See
|
||||||
|
[Dashboard Interface Design](./DashboardInterfaceDesign.md) for how the kit's
|
||||||
|
tokens and components shape the visual language.
|
||||||
|
|
||||||
## Hosting Model
|
## Hosting Model
|
||||||
|
|
||||||
@@ -67,8 +97,8 @@ Endpoint layout:
|
|||||||
The `/galaxy` page surfaces the Galaxy Repository browse summary
|
The `/galaxy` page surfaces the Galaxy Repository browse summary
|
||||||
(deployed object hierarchy size, last deploy timestamp, attribute totals,
|
(deployed object hierarchy size, last deploy timestamp, attribute totals,
|
||||||
template usage, and connectivity sync info). The summary is fed by
|
template usage, and connectivity sync info). The summary is fed by
|
||||||
`GalaxySummaryCache`, which is refreshed off the request path by
|
`GalaxyHierarchyCache`, which is refreshed off the request path by
|
||||||
`GalaxySummaryRefreshService` on the
|
`GalaxyHierarchyRefreshService` on the
|
||||||
`MxGateway:Galaxy:DashboardRefreshIntervalSeconds` cadence so the dashboard
|
`MxGateway:Galaxy:DashboardRefreshIntervalSeconds` cadence so the dashboard
|
||||||
never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for
|
never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for
|
||||||
the underlying gRPC service.
|
the underlying gRPC service.
|
||||||
@@ -79,24 +109,31 @@ the underlying gRPC service.
|
|||||||
ZB.MOM.WW.MxGateway.Server
|
ZB.MOM.WW.MxGateway.Server
|
||||||
Dashboard/
|
Dashboard/
|
||||||
Components/
|
Components/
|
||||||
App.razor
|
App.razor (loads <ThemeHead/> / <ThemeScripts/>)
|
||||||
Routes.razor
|
Routes.razor
|
||||||
DashboardPageBase.cs
|
DashboardPageBase.cs
|
||||||
DashboardDisplay.cs
|
DashboardDisplay.cs
|
||||||
Layout/
|
Layout/
|
||||||
DashboardLayout.razor
|
MainLayout.razor (ThemeShell side-rail chassis)
|
||||||
|
LoginLayout.razor (minimal, no rail; hosts <LoginCard>)
|
||||||
Pages/
|
Pages/
|
||||||
DashboardHome.razor
|
DashboardHome.razor
|
||||||
|
Login.razor
|
||||||
SessionsPage.razor
|
SessionsPage.razor
|
||||||
SessionDetailsPage.razor
|
SessionDetailsPage.razor
|
||||||
WorkersPage.razor
|
WorkersPage.razor
|
||||||
EventsPage.razor
|
EventsPage.razor
|
||||||
|
AlarmsPage.razor
|
||||||
|
GalaxyPage.razor
|
||||||
|
BrowsePage.razor
|
||||||
ApiKeysPage.razor
|
ApiKeysPage.razor
|
||||||
SettingsPage.razor
|
SettingsPage.razor
|
||||||
Shared/
|
Shared/
|
||||||
MetricCard.razor
|
MetricCard.razor
|
||||||
StatusBadge.razor
|
StatusBadge.razor (adapter over kit <StatusPill>)
|
||||||
FaultList.razor
|
FaultList.razor
|
||||||
|
BrowseTreeNodeView.razor
|
||||||
|
ConfirmDialog.razor
|
||||||
DashboardSnapshotService.cs
|
DashboardSnapshotService.cs
|
||||||
DashboardAuthorizationHandler.cs
|
DashboardAuthorizationHandler.cs
|
||||||
DashboardAuthenticator.cs
|
DashboardAuthenticator.cs
|
||||||
@@ -244,10 +281,14 @@ Show:
|
|||||||
- admin Close session / Kill worker controls (Admin role only).
|
- admin Close session / Kill worker controls (Admin role only).
|
||||||
|
|
||||||
The Sessions list, the Workers list, and this details page all render the same
|
The Sessions list, the Workers list, and this details page all render the same
|
||||||
admin controls when the signed-in principal carries the `Admin` role; viewers
|
admin controls when the signed-in principal carries the `Administrator` role; viewers
|
||||||
and the localhost-anonymous bypass see no action affordances and the server
|
and the localhost-anonymous bypass see no action affordances and the server
|
||||||
re-checks the role on every invocation. Every destructive admin action is
|
re-checks the role on every invocation. Every destructive admin action is
|
||||||
gated by a confirmation dialog before it reaches `ISessionManager`.
|
gated by the shared `ConfirmDialog` component before it reaches
|
||||||
|
`ISessionManager`. `ConfirmDialog` is a reusable Bootstrap modal (title,
|
||||||
|
message, confirm/cancel buttons, and a busy state that disables both buttons
|
||||||
|
while the action runs); each page binds its open state and confirm/cancel
|
||||||
|
callbacks. The API keys page uses the same component.
|
||||||
|
|
||||||
- **Close session** routes through `ISessionManager.CloseSessionAsync`: the
|
- **Close session** routes through `ISessionManager.CloseSessionAsync`: the
|
||||||
worker is asked to shut down gracefully and is killed only as a fallback if
|
worker is asked to shut down gracefully and is killed only as a fallback if
|
||||||
@@ -288,8 +329,9 @@ it opt-in and redacted.
|
|||||||
|
|
||||||
### Browse page
|
### Browse page
|
||||||
|
|
||||||
`/dashboard/browse` lets an operator explore the Galaxy tag hierarchy and watch
|
`/browse` lets an operator explore the Galaxy tag hierarchy and watch
|
||||||
live values. The tree is built in-process by `DashboardBrowseTreeBuilder` from
|
live values. The tree is built in-process by the static
|
||||||
|
`DashboardBrowseTreeBuilder` (in `DashboardBrowseModel.cs`) from
|
||||||
`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a
|
`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a
|
||||||
render costs no gRPC call and no SQL round-trip. Each node shows its child
|
render costs no gRPC call and no SQL round-trip. Each node shows its child
|
||||||
objects and, when expanded, its attributes with attribute name, data type
|
objects and, when expanded, its attributes with attribute name, data type
|
||||||
@@ -306,8 +348,11 @@ diagnostic session/worker views.
|
|||||||
|
|
||||||
### Alarms page
|
### Alarms page
|
||||||
|
|
||||||
`/dashboard/alarms` lists the alarms the gateway's central alarm monitor
|
`/alarms` lists the alarms the gateway's central alarm monitor
|
||||||
currently holds as Active or ActiveAcked, refreshed every three seconds. It
|
currently holds as Active or ActiveAcked. The page injects
|
||||||
|
`IDashboardLiveDataService` and drives a `PeriodicTimer` poll loop that calls
|
||||||
|
`QueryAlarmsAsync` every three seconds, rather than subscribing to the snapshot
|
||||||
|
hub or holding a `CurrentAlarms` reference directly. It
|
||||||
defaults to showing unacknowledged `Active` alarms; filters add acknowledged
|
defaults to showing unacknowledged `Active` alarms; filters add acknowledged
|
||||||
alarms and narrow by area, severity range, and a reference/source/description
|
alarms and narrow by area, severity range, and a reference/source/description
|
||||||
text search. Cleared alarms are not retained — the gateway holds no
|
text search. Cleared alarms are not retained — the gateway holds no
|
||||||
@@ -335,7 +380,7 @@ the monitor never starts and the cache stays empty.
|
|||||||
|
|
||||||
### API keys page
|
### API keys page
|
||||||
|
|
||||||
`/dashboard/apikeys` lists the gateway's API keys and, for authorized
|
`/apikeys` lists the gateway's API keys and, for authorized
|
||||||
operators, manages them. It reads key metadata through the same
|
operators, manages them. It reads key metadata through the same
|
||||||
`IApiKeyAdminStore` the `apikey` CLI uses, so the dashboard and the CLI act
|
`IApiKeyAdminStore` the `apikey` CLI uses, so the dashboard and the CLI act
|
||||||
on one source of truth.
|
on one source of truth.
|
||||||
@@ -358,7 +403,7 @@ for what each constraint means and how it is enforced on the gRPC path.
|
|||||||
|
|
||||||
Create, Rotate, Revoke, and Delete controls render only when the signed-in
|
Create, Rotate, Revoke, and Delete controls render only when the signed-in
|
||||||
user is authorized. `DashboardApiKeyAuthorization.CanManage` requires an
|
user is authorized. `DashboardApiKeyAuthorization.CanManage` requires an
|
||||||
authenticated principal carrying the `Admin` role claim (resolved at login
|
authenticated principal carrying the `Administrator` role claim (resolved at login
|
||||||
from the user's LDAP groups via `MxGateway:Dashboard:GroupToRole`). A
|
from the user's LDAP groups via `MxGateway:Dashboard:GroupToRole`). A
|
||||||
`Viewer` role can read the table but sees no action controls, and an
|
`Viewer` role can read the table but sees no action controls, and an
|
||||||
anonymous localhost session shows the same read-only view.
|
anonymous localhost session shows the same read-only view.
|
||||||
@@ -385,10 +430,11 @@ Create and Rotate return the assembled `mxgw_<keyId>_<secret>` token **once**,
|
|||||||
in a one-time banner. It is never shown again, so the operator must copy it
|
in a one-time banner. It is never shown again, so the operator must copy it
|
||||||
immediately. This mirrors the `apikey create-key` / `rotate-key` CLI.
|
immediately. This mirrors the `apikey create-key` / `rotate-key` CLI.
|
||||||
|
|
||||||
Every management action appends an `api_key_audit` entry
|
Every management action writes an entry to the canonical `audit_event` store
|
||||||
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
|
through `IAuditWriter` (`dashboard-create-key`, `dashboard-rotate-key`,
|
||||||
`dashboard-delete-key`) with the key id and the caller's remote address.
|
`dashboard-revoke-key`, `dashboard-delete-key`) with the key id, the caller's
|
||||||
Secrets and pepper values are never logged.
|
remote address, and a correlation id. Secrets and pepper values are never
|
||||||
|
logged.
|
||||||
|
|
||||||
### Settings page
|
### Settings page
|
||||||
|
|
||||||
@@ -408,23 +454,33 @@ Do not show API key secrets or pepper values.
|
|||||||
|
|
||||||
Dashboard authentication is LDAP-backed, distinct from the API-key model used
|
Dashboard authentication is LDAP-backed, distinct from the API-key model used
|
||||||
on the gRPC API. Users sign in with directory credentials; the gateway maps
|
on the gRPC API. Users sign in with directory credentials; the gateway maps
|
||||||
their LDAP groups to one of two dashboard roles (`Admin` or `Viewer`) and
|
their LDAP groups to one of two dashboard roles (`Administrator` or `Viewer`) and
|
||||||
issues a cookie carrying those role claims.
|
issues a cookie carrying those role claims.
|
||||||
|
|
||||||
Implemented behavior:
|
Implemented behavior:
|
||||||
|
|
||||||
- a static `/login` HTML form posts username/password to the gateway;
|
- `GET /login` is served by the `[AllowAnonymous]` Blazor `Login.razor`
|
||||||
- `DashboardAuthenticator` binds against `MxGateway:Ldap` (service-account bind,
|
component (under `LoginLayout`), which renders the shared kit's `<LoginCard>`.
|
||||||
user search, candidate bind) using `Novell.Directory.Ldap.NETStandard`;
|
`LoginCard` emits a native static `<form method="post" action="/login">`
|
||||||
- the user's `memberOf` (or short CN) is matched against
|
(username, password, hidden returnUrl) plus an `<AntiforgeryToken/>`. A native
|
||||||
`MxGateway:Dashboard:GroupToRole`; the resolved role(s) are emitted as
|
form submit is not a Blazor event, so it reaches the minimal-API `POST /login`
|
||||||
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
|
endpoint regardless of the app's InteractiveServer render mode;
|
||||||
claims;
|
- `DashboardAuthenticator` delegates bind/search to the shared
|
||||||
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
|
`ZB.MOM.WW.Auth.Ldap` provider, registered by `AddZbLdapAuth(configuration,
|
||||||
(`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure);
|
"MxGateway:Ldap")`. The provider performs a service-account bind, user search,
|
||||||
|
then candidate bind, and fails closed;
|
||||||
|
- the user's group membership (stripped to its first RDN by the provider) is
|
||||||
|
matched against `MxGateway:Dashboard:GroupToRole`; the resolved role(s) are
|
||||||
|
emitted as `ClaimTypes.Role` claims, alongside the per-group
|
||||||
|
`mxgateway:ldap_group` claims;
|
||||||
|
- a successful login signs in the `MxGateway.Dashboard` cookie scheme. The
|
||||||
|
cookie defaults to the name `MxGatewayDashboard` (HttpOnly, SameSite=Strict,
|
||||||
|
Secure) and can be overridden via `MxGateway:Dashboard:CookieName`;
|
||||||
- a user with no matching group cannot sign in — the login screen returns the
|
- a user with no matching group cannot sign in — the login screen returns the
|
||||||
generic credential-rejected message;
|
generic credential-rejected message via `/login?error=…`;
|
||||||
- antiforgery tokens guard the login and logout POSTs.
|
- antiforgery tokens guard the login and logout POSTs. `POST /logout` (and a
|
||||||
|
`GET /logout` convenience redirect) sign the cookie out and return to
|
||||||
|
`/login`.
|
||||||
|
|
||||||
Three authorization policies are registered:
|
Three authorization policies are registered:
|
||||||
|
|
||||||
@@ -443,8 +499,8 @@ Viewer role.
|
|||||||
|
|
||||||
### Hub bearer flow
|
### Hub bearer flow
|
||||||
|
|
||||||
SignalR connections cannot reuse the `__Host-` cookie when the JS client
|
SignalR connections cannot reuse the `MxGatewayDashboard` cookie when the JS
|
||||||
upgrades to WebSocket — the cookie's `SameSite=Strict; Path=/` keeps it from
|
client upgrades to WebSocket — the cookie's `SameSite=Strict; Path=/` keeps it from
|
||||||
being forwarded by the browser's WebSocket layer in some edge cases. The
|
being forwarded by the browser's WebSocket layer in some edge cases. The
|
||||||
dashboard mints short-lived bearer tokens for the connection:
|
dashboard mints short-lived bearer tokens for the connection:
|
||||||
|
|
||||||
@@ -480,8 +536,10 @@ Effective configuration:
|
|||||||
"RecentFaultLimit": 100,
|
"RecentFaultLimit": 100,
|
||||||
"RecentSessionLimit": 200,
|
"RecentSessionLimit": 200,
|
||||||
"ShowTagValues": false,
|
"ShowTagValues": false,
|
||||||
|
"CookieName": null,
|
||||||
|
"RequireHttpsCookie": true,
|
||||||
"GroupToRole": {
|
"GroupToRole": {
|
||||||
"GwAdmin": "Admin",
|
"GwAdmin": "Administrator",
|
||||||
"GwReader": "Viewer"
|
"GwReader": "Viewer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,6 +547,15 @@ Effective configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Two cookie keys tune the auth cookie:
|
||||||
|
|
||||||
|
- `CookieName` overrides the cookie name. Null or blank keeps the canonical
|
||||||
|
default `MxGatewayDashboard`, so a misconfiguration cannot leave the cookie
|
||||||
|
unnamed.
|
||||||
|
- `RequireHttpsCookie` (default `true`) sets the cookie `SecurePolicy` to
|
||||||
|
`Always`. Set it to `false` for dev HTTP deployments, which relaxes the policy
|
||||||
|
to `SameAsRequest`.
|
||||||
|
|
||||||
See [Gateway Configuration](./GatewayConfiguration.md#dashboard-options) for
|
See [Gateway Configuration](./GatewayConfiguration.md#dashboard-options) for
|
||||||
the full option table and the policies/hubs that derive from these values.
|
the full option table and the policies/hubs that derive from these values.
|
||||||
|
|
||||||
@@ -504,17 +571,31 @@ the full option table and the policies/hubs that derive from these values.
|
|||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
The dashboard serves Bootstrap 5.3.3 assets from
|
Styling is layered. From base to top:
|
||||||
`src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling
|
|
||||||
from `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css`.
|
1. Bootstrap 5.3.3 assets served from
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/`.
|
||||||
|
2. The `ZB.MOM.WW.Theme` kit's `theme.css` (the technical-light design system),
|
||||||
|
which owns the design tokens and the kit component styles. `App.razor` loads
|
||||||
|
it through the kit's `<ThemeHead/>` component, and pairs it with
|
||||||
|
`<ThemeScripts/>` at the end of `<body>` for the rail's interactive behavior.
|
||||||
|
3. The local view stylesheet
|
||||||
|
`src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css`, which wires the
|
||||||
|
dashboard's own class names and Bootstrap widgets onto the kit tokens. It
|
||||||
|
defines no hard-coded colors.
|
||||||
|
|
||||||
|
The minimal `/denied` page is rendered outside the Blazor circuit, so it loads
|
||||||
|
the kit CSS directly from the static-web-asset path
|
||||||
|
(`/_content/ZB.MOM.WW.Theme/css/theme.css` and `…/layout.css`) plus Bootstrap
|
||||||
|
and `site.css`.
|
||||||
|
|
||||||
Recommended visual language:
|
Recommended visual language:
|
||||||
|
|
||||||
- compact tables,
|
- compact tables,
|
||||||
- status badges,
|
- the kit `StatusPill` for state,
|
||||||
- metric cards,
|
- metric cards,
|
||||||
- Bootstrap alerts for faults,
|
- Bootstrap alerts for faults,
|
||||||
- restrained colors,
|
- restrained colors drawn from the kit tokens,
|
||||||
- no decorative hero sections,
|
- no decorative hero sections,
|
||||||
- no charting dependency for v1.
|
- no charting dependency for v1.
|
||||||
|
|
||||||
@@ -530,7 +611,7 @@ Dashboard unit/component tests should cover:
|
|||||||
|
|
||||||
- snapshot projection,
|
- snapshot projection,
|
||||||
- dashboard auth authorization decisions,
|
- dashboard auth authorization decisions,
|
||||||
- login API-key validation behavior,
|
- login LDAP bind and group-to-role mapping behavior,
|
||||||
- pages render with empty state,
|
- pages render with empty state,
|
||||||
- pages render with active sessions,
|
- pages render with active sessions,
|
||||||
- pages render with faulted sessions,
|
- pages render with faulted sessions,
|
||||||
@@ -557,7 +638,8 @@ Integration tests should verify:
|
|||||||
The first dashboard slice implements:
|
The first dashboard slice implements:
|
||||||
|
|
||||||
1. Blazor Server hosting in `ZB.MOM.WW.MxGateway.Server`.
|
1. Blazor Server hosting in `ZB.MOM.WW.MxGateway.Server`.
|
||||||
2. local Bootstrap static assets.
|
2. local Bootstrap static assets plus the `ZB.MOM.WW.Theme` kit layer
|
||||||
|
(chassis, tokens, status components).
|
||||||
3. dashboard configuration binding.
|
3. dashboard configuration binding.
|
||||||
4. dashboard auth using LDAP bind + role-mapped HTTP-only cookie.
|
4. dashboard auth using LDAP bind + role-mapped HTTP-only cookie.
|
||||||
5. `DashboardSnapshotService` projecting gateway state for read views.
|
5. `DashboardSnapshotService` projecting gateway state for read views.
|
||||||
|
|||||||
@@ -247,12 +247,17 @@ Technology:
|
|||||||
Suggested routes:
|
Suggested routes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/dashboard
|
/
|
||||||
/dashboard/sessions
|
/login
|
||||||
/dashboard/sessions/{sessionId}
|
/sessions
|
||||||
/dashboard/workers
|
/sessions/{sessionId}
|
||||||
/dashboard/events
|
/workers
|
||||||
/dashboard/settings
|
/events
|
||||||
|
/alarms
|
||||||
|
/galaxy
|
||||||
|
/browse
|
||||||
|
/apikeys
|
||||||
|
/settings
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard pages:
|
Dashboard pages:
|
||||||
@@ -681,13 +686,14 @@ Dashboard authentication uses LDAP bind + role mapping (separate from the
|
|||||||
API-key model used on the gRPC API). The login endpoint accepts username and
|
API-key model used on the gRPC API). The login endpoint accepts username and
|
||||||
password in a form post, calls `DashboardAuthenticator` to bind against
|
password in a form post, calls `DashboardAuthenticator` to bind against
|
||||||
`MxGateway:Ldap`, resolves the user's LDAP groups through
|
`MxGateway:Ldap`, resolves the user's LDAP groups through
|
||||||
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
|
`MxGateway:Dashboard:GroupToRole` to one of `Administrator` / `Viewer`, and signs in
|
||||||
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
|
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
|
||||||
secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout
|
secure, strict SameSite, and named `MxGatewayDashboard` (configurable via
|
||||||
|
`MxGateway:Dashboard:CookieName`). Logout
|
||||||
clears it. Login and logout posts validate antiforgery tokens. SignalR
|
clears it. Login and logout posts validate antiforgery tokens. SignalR
|
||||||
connections additionally accept a 30-minute data-protected bearer minted at
|
connections additionally accept a 30-minute data-protected bearer minted at
|
||||||
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
|
`/hubs/token`. `MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback
|
||||||
to bypass the cookie requirement and defaults to `true`.
|
requests to bypass the cookie requirement and defaults to `true`.
|
||||||
|
|
||||||
Recommended scopes:
|
Recommended scopes:
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user